Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ac4867821 | ||
|
|
b0996b0c6e | ||
|
|
850a04c4a0 | ||
|
|
439c1b9cdd | ||
|
|
2ae8f1ddf9 | ||
|
|
912c077da1 | ||
|
|
9b378b4237 | ||
|
|
93897ce516 | ||
|
|
fc49ee1cfb | ||
|
|
ee7d9036a2 | ||
|
|
4616ee80e3 | ||
|
|
e6b66d9e34 | ||
|
|
fc90af3a62 | ||
|
|
f6d95ca338 | ||
|
|
db4c3b2842 | ||
|
|
830049b339 | ||
|
|
fcbc2814c2 | ||
|
|
897a0606ba | ||
|
|
c13c3093cf | ||
|
|
bb8d1857b4 | ||
|
|
c7fce1a53d |
No files matched your search
@@ -124,6 +124,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
|
||||
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
|
||||
private games.dmg.fantasystick.ability.GreatSword greatSword;
|
||||
private games.dmg.fantasystick.persistence.GreatSwordSettings greatSwordSettings;
|
||||
private games.dmg.fantasystick.ability.Revive revive;
|
||||
private games.dmg.fantasystick.persistence.RevivalStore revivals;
|
||||
private games.dmg.fantasystick.session.AdminRevival adminRevival;
|
||||
@@ -173,12 +174,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
|
||||
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
|
||||
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
|
||||
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> adminRevivalAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.PartyEventBridge partyAuditEvents;
|
||||
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.SwitchConfigEventBridge switchConfigEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> switchConfigAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> identityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentAuditEvents;
|
||||
@@ -188,6 +195,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.events.MessageEventBridge messageAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
private void dispatchOwnedHealing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
|
||||
var policy = nutritionPolicy;
|
||||
if (policy == null) getServer().getPluginManager().callEvent(event);
|
||||
else policy.dispatchOwnedHealing(event, getServer().getPluginManager()::callEvent);
|
||||
}
|
||||
|
||||
@Override public void onEnable() {
|
||||
try {
|
||||
Object generation = new Object();
|
||||
@@ -253,7 +266,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var rewardRules = combatRewardRules(hardMobXp);
|
||||
var fireballConfiguration = fireballConfiguration();
|
||||
var fireballInitial = games.dmg.fantasystick.persistence.FireballSettings.parse(fireballConfiguration);
|
||||
var greatSwordSettings = greatSwordSettings();
|
||||
var greatSwordConfiguration = greatSwordConfiguration();
|
||||
var greatSwordInitial = games.dmg.fantasystick.persistence.GreatSwordSettings.parse(greatSwordConfiguration);
|
||||
var frostShardConfiguration = frostShardConfiguration();
|
||||
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
|
||||
var longbowConfiguration = longbowConfiguration();
|
||||
@@ -441,7 +455,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 +476,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 +502,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
sanctuarySettings = new games.dmg.fantasystick.persistence.SanctuarySettings(
|
||||
getDataFolder().toPath(), sanctuaryConfiguration);
|
||||
sanctuary = new games.dmg.fantasystick.ability.Sanctuary(sanctuaryInitial, characters, sessions, parties,
|
||||
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
combatMitigation, nextTick, this::dispatchOwnedHealing,
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()),
|
||||
particleDelivery);
|
||||
@@ -498,7 +512,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
warBannerInitial, sessions, parties, outgoingDamageBonus, particleDelivery);
|
||||
meteorSettings = new games.dmg.fantasystick.persistence.MeteorSettings(getDataFolder().toPath(), meteorConfiguration);
|
||||
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties, particleDelivery);
|
||||
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
|
||||
greatSwordSettings = new games.dmg.fantasystick.persistence.GreatSwordSettings(
|
||||
getDataFolder().toPath(), greatSwordConfiguration);
|
||||
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordInitial);
|
||||
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
|
||||
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
|
||||
(size, title) -> getServer().createInventory(null, size, title),
|
||||
@@ -528,11 +544,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 +562,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 +635,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
rareShopMenus=new games.dmg.fantasystick.shop.RareShopMenus(this,rareShop);var localRareMenus=rareShopMenus;
|
||||
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;if(rareEvents!=null)rareEvents.catalogRefreshed(catalog);try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
|
||||
rareMerchantController=new games.dmg.fantasystick.shop.RareMerchantController(this,rareMerchants,rareShopMenus::open);
|
||||
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,event->getServer().getPluginManager().callEvent(event),nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
|
||||
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,this::dispatchOwnedHealing,nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
|
||||
rareReskills=new games.dmg.fantasystick.shop.RareReskillService(rareInventory,characters,sessions,nextTick,record->{for(String ability:record.learned())abilityEngine.remove(record.id(),ability);speedI.sweep();lowLightVision.sweep();},failure->getLogger().log(Level.WARNING,"Rare Reskill use failed",failure));
|
||||
rareMagicCores=new games.dmg.fantasystick.shop.RareMagicCoreService(rareInventory,characters,sessions,nextTick,failure->getLogger().log(Level.WARNING,"Rare Magic Core use failed",failure));
|
||||
rarePotions.install();rareReskills.install();rareMagicCores.install();books.attachRareInventory(rareInventory,rarePotions,rareReskills,rareMagicCores);
|
||||
@@ -660,6 +677,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
|
||||
inspireSettings, inspire, abilityRegistry);
|
||||
admin.attachFireball(fireballSettings, fireball);
|
||||
admin.attachGreatSword(greatSwordSettings, greatSword);
|
||||
admin.attachMendingLight(mendingLightSettings, mendingLight);
|
||||
admin.attachPurify(purifySettings, purify);
|
||||
admin.attachGuardianBond(guardianBondSettings, guardianBond);
|
||||
@@ -727,10 +745,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
particlesCommand.attachEvents(preferenceEvents);
|
||||
feedbackCommand.attachEvents(preferenceEvents);
|
||||
eventCapture.excludeOwnedDamage(fireball::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(frostShard::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(longbow::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(greatSword::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(shieldBash::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(guardianBond::ownsAcceptedRedirect);
|
||||
eventCapture.excludeOwnedDamage(frostNova::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(chainLightning::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(meteor::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(localBasicAttack::ownsAcceptedDamage);
|
||||
localBasicAttack.attachEvents(new games.dmg.fantasystick.events.BasicAttackEventBridge(
|
||||
eventLog::emit, Clock.systemUTC()));
|
||||
downedEvents = new games.dmg.fantasystick.events.DownedEventBridge(eventLog::emit, Clock.systemUTC());
|
||||
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC());rareShop.attachEvents(rareEvents);
|
||||
bookEvents = new games.dmg.fantasystick.events.BookEventBridge(eventLog::emit, Clock.systemUTC());books.attachEvents(bookEvents);
|
||||
@@ -738,6 +763,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityEvents = new games.dmg.fantasystick.events.AbilityEventBridge(eventLog::emit, Clock.systemUTC());books.attachAbilityEvents(abilityEvents);
|
||||
abilityEngine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(eventLog::emit, Clock.systemUTC()));
|
||||
abilityEngine.enableCastEventsFor("fireball");
|
||||
abilityEngine.enableCastEventsFor("great_sword");
|
||||
abilityEngine.enableCastEventsFor("frost_shard");
|
||||
abilityEngine.enableCastEventsFor("longbow");
|
||||
abilityEngine.enableCastEventsFor("quickstep");
|
||||
abilityEngine.enableCastEventsFor("second_wind");
|
||||
abilityEngine.enableCastEventsFor("stone_skin");
|
||||
abilityEngine.enableCastEventsFor("shield_bash");
|
||||
abilityEngine.enableCastEventsFor("hex");
|
||||
abilityEngine.enableCastEventsFor("inspire");
|
||||
abilityEngine.enableCastEventsFor("mending_light");
|
||||
abilityEngine.enableCastEventsFor("purify");
|
||||
abilityEngine.enableCastEventsFor("guardian_bond");
|
||||
@@ -751,22 +785,37 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
passiveEvents = new games.dmg.fantasystick.events.PassiveEventBridge(eventLog::emit, Clock.systemUTC());books.attachPassiveEvents(passiveEvents);admin.attachPassiveEvents(passiveEvents);
|
||||
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(eventLog::emit, Clock.systemUTC());outOfCombatRecovery.attachEvents(recoveryEvents,record->record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)?quickerRegeneration.bonus():java.math.BigInteger.ZERO);
|
||||
reviveEvents = new games.dmg.fantasystick.events.ReviveEventBridge(eventLog::emit, Clock.systemUTC());revive.attachEvents(reviveEvents);
|
||||
adminRevivalEvents = new games.dmg.fantasystick.events.AdminRevivalEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Admin revival event intent failed", failure));
|
||||
admin.attachAdminRevivalEvents(adminRevivalEvents);
|
||||
revive.attachAdminRevivalEvents(adminRevivalEvents);
|
||||
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
|
||||
partyCombat.attachProtectionEvents(new games.dmg.fantasystick.events.PartyProtectionEventBridge(
|
||||
eventLog::emit, Clock.systemUTC()));
|
||||
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));
|
||||
sessions.attachSwitchEvents(switchAuditEvents);
|
||||
switchConfigEvents = new games.dmg.fantasystick.events.SwitchConfigEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Switch configuration event intent failed", failure));
|
||||
admin.attachSwitchConfigEvents(switchConfigEvents);
|
||||
var identitySessions = sessions;
|
||||
identityAuditEvents = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
playerId -> identitySessions.active(playerId).map(games.dmg.fantasystick.session.EquipmentSessions.Active::session),
|
||||
failure -> getLogger().log(Level.WARNING, "Character identity event intent failed", failure));
|
||||
crafting.attachIdentityEvents(identityAuditEvents);
|
||||
admin.attachCharacterIssueEvents(identityAuditEvents);
|
||||
anvilNames.attachIdentityEvents(identityAuditEvents);
|
||||
sessions.attachIdentityEvents(identityAuditEvents);
|
||||
equipmentAuditEvents = new games.dmg.fantasystick.events.EquipmentEventBridge(
|
||||
@@ -787,7 +836,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; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; switchConfigEvents = 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,11 +917,26 @@ 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 replaySwitchConfigEvents = switchConfigEvents;
|
||||
switchConfigAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous switch configuration intent drain failed", failure);
|
||||
if (replaySwitchConfigEvents != null) replaySwitchConfigEvents.replay();
|
||||
});
|
||||
var replaySwitchEvents = switchAuditEvents;
|
||||
switchAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous switch event intent drain failed", failure);
|
||||
if (replaySwitchEvents != null) replaySwitchEvents.replay();
|
||||
});
|
||||
var replayAdminRevivalEvents = adminRevivalEvents;
|
||||
adminRevivalAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous admin revival event intent drain failed", failure);
|
||||
if (replayAdminRevivalEvents != null) replayAdminRevivalEvents.replay();
|
||||
});
|
||||
var replayIdentityEvents = identityAuditEvents;
|
||||
identityAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous identity event intent drain failed", failure);
|
||||
@@ -926,8 +990,11 @@ 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 closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
|
||||
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
|
||||
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
|
||||
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
|
||||
var closingSessionRecoveryAuditEvents = sessionRecoveryAuditEvents; sessionRecoveryAuditEvents = null;
|
||||
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
|
||||
@@ -971,6 +1038,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingAbilityEngine = abilityEngine; abilityEngine = null;
|
||||
var closingFireball = fireball; fireball = null;
|
||||
var closingFireballSettings = fireballSettings; fireballSettings = null;
|
||||
var closingGreatSwordSettings = greatSwordSettings; greatSwordSettings = null;
|
||||
var closingFrostShard = frostShard; frostShard = null;
|
||||
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
|
||||
var closingLongbow = longbow; longbow = null;
|
||||
@@ -1045,6 +1113,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
|
||||
if (closingSettings != null) cleanup("configuration", closingSettings::close);
|
||||
if (closingFireballSettings != null) cleanup("Fireball configuration", closingFireballSettings::close);
|
||||
if (closingGreatSwordSettings != null) cleanup("Great Sword configuration", closingGreatSwordSettings::close);
|
||||
if (closingFrostSettings != null) cleanup("Frost Shard configuration", closingFrostSettings::close);
|
||||
if (closingLongbowSettings != null) cleanup("Longbow configuration", closingLongbowSettings::close);
|
||||
if (closingQuickstepSettings != null) cleanup("Quickstep configuration", closingQuickstepSettings::close);
|
||||
@@ -1131,6 +1200,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
: closingSettings.drained().toCompletableFuture(),
|
||||
closingFireballSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingFireballSettings.drained().toCompletableFuture(),
|
||||
closingGreatSwordSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingGreatSwordSettings.drained().toCompletableFuture(),
|
||||
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingFrostSettings.drained().toCompletableFuture(),
|
||||
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -1234,6 +1305,25 @@ 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 (closingSwitchConfigEvents != null) cleanup("switch configuration event intent drain", () -> {
|
||||
var settingsDrained = closingSwitchDelaySettings == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
|
||||
: closingSwitchDelaySettings.drained();
|
||||
switchConfigAuditDrain = settingsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Switch setting drain failed", failure);
|
||||
closingSwitchConfigEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingSwitchConfigEvents.drained());
|
||||
});
|
||||
if (closingSwitchAuditEvents != null) cleanup("switch event intent drain", () -> {
|
||||
var sessionsDrained = closingSessions == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
|
||||
@@ -1308,6 +1398,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
|
||||
});
|
||||
if (closingCharacters != null) cleanup("character storage", closingCharacters::close);
|
||||
if (closingAdminRevivalAuditEvents != null) cleanup("admin revival event intent drain", () -> {
|
||||
var charactersDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
adminRevivalAuditDrain = charactersDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character save drain failed", failure);
|
||||
closingAdminRevivalAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingAdminRevivalAuditEvents.drained());
|
||||
});
|
||||
if (closingIdentityAuditEvents != null) cleanup("character identity event intent drain", () -> {
|
||||
var charactersDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
@@ -1548,15 +1647,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
|
||||
try {
|
||||
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
|
||||
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "0.75"));
|
||||
var recovery = java.time.Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
|
||||
return new games.dmg.fantasystick.ability.GreatSword.Settings(base, recovery);
|
||||
} catch (ArithmeticException | NumberFormatException invalid) {
|
||||
throw new IllegalArgumentException("Great Sword settings must use supported nonnegative numeric values", invalid);
|
||||
}
|
||||
private java.util.Map<String, String> greatSwordConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (var key : games.dmg.fantasystick.persistence.GreatSwordSettings.keys())
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.GreatSwordSettings.defaultValue(key)));
|
||||
games.dmg.fantasystick.persistence.GreatSwordSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private void notifyPlayer(Object generation, EquipmentSessions.Notice notice) {
|
||||
|
||||
@@ -69,6 +69,16 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
if (castEvents != null) castEvents.resolved(audit, outcome, reason);
|
||||
}
|
||||
|
||||
private void refuseCast(ControlInput input, CharacterRecord record, AbilityDefinition definition, String reason) {
|
||||
if (castEvents == null || record == null
|
||||
|| !(definition.id().equals("great_sword") || definition.id().equals("longbow")
|
||||
|| definition.id().equals("quickstep") || definition.id().equals("second_wind")
|
||||
|| definition.id().equals("shield_bash") || definition.id().equals("hex")
|
||||
|| definition.id().equals("inspire"))
|
||||
|| !eventOwnedAbilities.contains(definition.id())) return;
|
||||
resolve(castEvents.begin(input, record, definition), "refused", reason);
|
||||
}
|
||||
|
||||
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
|
||||
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
|
||||
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
|
||||
@@ -170,11 +180,13 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
var record = sessions.currentRecord(input.session()).orElse(null);
|
||||
if (record == null || !record.learned().contains(definition.id())
|
||||
|| input.slot() < 2 || !record.loadout().get(input.slot() - 2).equals(definition.id())) {
|
||||
refuseCast(input, record, definition, "not_learned_or_assigned");
|
||||
input.player().sendMessage("That ability is not learned and assigned to this control."); return;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
Instant existing = record.cooldowns().get(definition.id());
|
||||
if (existing != null && now.isBefore(existing)) {
|
||||
refuseCast(input, record, definition, "cooldown_active");
|
||||
input.player().sendMessage("" + definition.name() + " is ready in "
|
||||
+ Math.max(1, Duration.between(now, existing).toSeconds()) + " seconds."); return;
|
||||
}
|
||||
@@ -185,11 +197,15 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
input.player().sendMessage("That target could not be validated for " + definition.name() + "."); return;
|
||||
}
|
||||
if (!accepted) {
|
||||
refuseCast(input, record, definition, "target_ineligible");
|
||||
input.player().sendMessage("That target is not eligible for " + definition.name() + "."); return;
|
||||
}
|
||||
var key = new Key(input.session().character(), definition.id());
|
||||
Object token = new Object();
|
||||
if (pending.putIfAbsent(key, token) != null) { input.player().sendMessage("That cast is still being saved."); return; }
|
||||
if (pending.putIfAbsent(key, token) != null) {
|
||||
refuseCast(input, record, definition, "save_in_progress");
|
||||
input.player().sendMessage("That cast is still being saved."); return;
|
||||
}
|
||||
long generation = generations.getOrDefault(key, 0L);
|
||||
Instant readyAt;
|
||||
try { readyAt = now.plus(definition.cooldown()); }
|
||||
@@ -239,25 +255,31 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
return;
|
||||
}
|
||||
CharacterRecord saved = mutation.record().orElseThrow();
|
||||
if (castEvents != null) castEvents.cast(audit, saved, readyAt, definition);
|
||||
boolean deferNativeCast = audit != null && definition.id().equals("quickstep");
|
||||
if (castEvents != null && !deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
|
||||
boolean synchronizedProgress = unchangedDeadline
|
||||
? sessions.currentRecord(input.session()).filter(saved::equals).isPresent()
|
||||
: sessions.acknowledgeProgress(input.session(), expectedRevision, saved);
|
||||
if (!synchronizedProgress) {
|
||||
if (deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
|
||||
resolve(audit, "interrupted", "session_publication_changed");
|
||||
input.player().sendMessage("The cooldown was saved, but the session changed; no effect was applied."); return;
|
||||
}
|
||||
if (!input.stillCurrent() || generations.getOrDefault(key, 0L) != generation
|
||||
|| !eligible(definition.targetPolicy(), input, input.target())) {
|
||||
if (deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
|
||||
resolve(audit, "interrupted", "eligibility_changed");
|
||||
input.player().sendMessage("The cooldown was saved, but eligibility changed; no effect was applied."); return;
|
||||
}
|
||||
var runtime = new Runtime(key, input, definition, generation, audit);
|
||||
var runtime = new Runtime(key, input, definition, generation, audit,
|
||||
deferNativeCast ? saved : null, readyAt);
|
||||
active.computeIfAbsent(key, ignored -> new HashSet<>()).add(runtime);
|
||||
try {
|
||||
definition.effect().activate(runtime);
|
||||
runtime.publishCast();
|
||||
if (definition.activation() == AbilityDefinition.Activation.IMMEDIATE) runtime.finish();
|
||||
} catch (RuntimeException effectFailure) {
|
||||
runtime.publishCast();
|
||||
runtime.resolve("failed"); runtime.close(); report(key.character(), key.ability(), effectFailure);
|
||||
input.player().sendMessage("The cooldown was saved, but the ability effect failed safely.");
|
||||
}
|
||||
@@ -328,17 +350,41 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
private final AbilityDefinition definition;
|
||||
private final long generation;
|
||||
private final games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit;
|
||||
private final CharacterRecord deferredCastRecord;
|
||||
private final Instant deferredDeadline;
|
||||
private final Map<String, Object> castEvidence = new HashMap<>();
|
||||
private boolean castPublished;
|
||||
private final Map<UUID, String> observedEffects = new HashMap<>();
|
||||
private final Map<UUID, Map<String, Object>> observedEndEvidence = new HashMap<>();
|
||||
private final Set<AutoCloseable> owned = new HashSet<>();
|
||||
private String outcome;
|
||||
private Map<String, Object> resolutionEvidence = Map.of();
|
||||
private boolean finished;
|
||||
private boolean closing;
|
||||
private int scheduled;
|
||||
|
||||
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation,
|
||||
games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit) {
|
||||
games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit,
|
||||
CharacterRecord deferredCastRecord, Instant deferredDeadline) {
|
||||
this.key = key; this.input = input; this.definition = definition; this.generation = generation;
|
||||
this.audit = audit;
|
||||
this.audit = audit; this.deferredCastRecord = deferredCastRecord; this.deferredDeadline = deferredDeadline;
|
||||
}
|
||||
void publishCast() {
|
||||
if (deferredCastRecord == null || castPublished) return;
|
||||
castPublished = true;
|
||||
if (castEvents != null) castEvents.cast(audit, deferredCastRecord, deferredDeadline, definition,
|
||||
Map.copyOf(castEvidence));
|
||||
}
|
||||
@Override public void castEvidence(Map<String, Object> evidence) {
|
||||
if (!castPublished && !finished && deferredCastRecord != null)
|
||||
castEvidence.putAll(Objects.requireNonNull(evidence));
|
||||
}
|
||||
@Override public void resolutionEvidence(Map<String, Object> evidence) {
|
||||
if (!finished) {
|
||||
var combined = new java.util.LinkedHashMap<>(resolutionEvidence);
|
||||
combined.putAll(Objects.requireNonNull(evidence));
|
||||
resolutionEvidence = Map.copyOf(combined);
|
||||
}
|
||||
}
|
||||
@Override public AbilityDefinition definition() { return definition; }
|
||||
@Override public ControlInput input() { return input; }
|
||||
@@ -346,7 +392,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
return sessions.currentRecord(input.session()).orElseThrow(() -> new IllegalStateException("Ability session ended"));
|
||||
}
|
||||
@Override public boolean stillCurrent() {
|
||||
return !closed && !finished && input.stillCurrent() && sessions.current(input.session())
|
||||
return !closed && !finished && !closing && input.stillCurrent() && sessions.current(input.session())
|
||||
&& generations.getOrDefault(key, 0L) == generation
|
||||
&& sessions.currentRecord(input.session()).filter(record -> record.learned().contains(key.ability())).isPresent();
|
||||
}
|
||||
@@ -401,6 +447,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
try { feedback.report(definition, input, type, target, report.amount()); }
|
||||
catch (RuntimeException failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
|
||||
}
|
||||
@Override public java.time.Instant now() { return clock.instant(); }
|
||||
@Override public UUID effectApplied(String effectId, Entity target, Map<String, Object> evidence) {
|
||||
if (!stillCurrent()) throw new IllegalStateException("Ability execution ended");
|
||||
Objects.requireNonNull(effectId); Objects.requireNonNull(evidence);
|
||||
@@ -439,6 +486,29 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
castEvents.prevented(audit, ally, aided.character(), aided.session(), attacker,
|
||||
incoming, actual, remaining, bondApplication);
|
||||
}
|
||||
@Override public void acceptedOutgoingMitigation(UUID application, Entity debuffedAttacker,
|
||||
Entity protectedTarget, double incoming, double remaining, double actual) {
|
||||
if (castEvents == null || audit == null) return;
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
if (protectedTarget instanceof Player player)
|
||||
sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(active -> {
|
||||
evidence.put("protected_character_id", active.character().toString());
|
||||
evidence.put("protected_session_id", active.session().toString());
|
||||
});
|
||||
castEvents.outgoingPrevented(audit, application, debuffedAttacker, protectedTarget,
|
||||
incoming, remaining, actual, evidence);
|
||||
}
|
||||
@Override public void acceptedAbsorption(UUID application, Player target, Entity attacker,
|
||||
double incoming, double before, double after,
|
||||
double consumed, double remaining,
|
||||
org.bukkit.Location targetAtHit, org.bukkit.Location attackerAtHit) {
|
||||
if (castEvents == null || audit == null) return;
|
||||
try {
|
||||
var aided = sessions.active(target.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
castEvents.absorbed(audit, application, target, aided, attacker, incoming,
|
||||
before, after, consumed, remaining, targetAtHit, attackerAtHit);
|
||||
} catch (RuntimeException ignored) { /* Recording cannot change native absorption or support. */ }
|
||||
}
|
||||
@Override public void acceptedBoost(EquipmentSessions.Active attacking, Player attacker,
|
||||
Entity target, double base, double bonus, double finalDamage,
|
||||
java.math.BigDecimal percent) {
|
||||
@@ -446,14 +516,23 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
castEvents.boosted(audit, attacking, attacker, target, base, bonus, finalDamage, percent);
|
||||
}
|
||||
@Override public void effectEnded(UUID application, String reason) {
|
||||
Objects.requireNonNull(application); Objects.requireNonNull(reason);
|
||||
effectEnded(application, reason, observedEndEvidence.getOrDefault(application, Map.of()));
|
||||
}
|
||||
@Override public void effectEndEvidence(UUID application, Map<String, Object> evidence) {
|
||||
if (observedEffects.containsKey(application))
|
||||
observedEndEvidence.put(application, Map.copyOf(Objects.requireNonNull(evidence)));
|
||||
}
|
||||
@Override public void effectEnded(UUID application, String reason, Map<String, Object> evidence) {
|
||||
Objects.requireNonNull(application); Objects.requireNonNull(reason); Objects.requireNonNull(evidence);
|
||||
var effectId = observedEffects.remove(application);
|
||||
if (effectId != null && castEvents != null) castEvents.effectEnded(audit, application, effectId, reason);
|
||||
observedEndEvidence.remove(application);
|
||||
if (effectId != null && castEvents != null)
|
||||
castEvents.effectEnded(audit, application, effectId, reason, evidence);
|
||||
}
|
||||
@Override public void resolve(String result) { resolve(result, Map.of()); }
|
||||
@Override public void resolve(String result, Map<String, Object> evidence) {
|
||||
if (!Set.of("completed", "hit", "miss", "expired", "interrupted", "failed", "cancelled",
|
||||
"healed", "cleansed", "protected", "boosted").contains(result))
|
||||
"healed", "cleansed", "protected", "boosted", "obstructed", "replaced").contains(result))
|
||||
throw new IllegalArgumentException("Unsupported ability resolution");
|
||||
if (!finished) { outcome = result; resolutionEvidence = Map.copyOf(Objects.requireNonNull(evidence)); }
|
||||
}
|
||||
@@ -462,11 +541,13 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
close();
|
||||
}
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (finished || closing) return;
|
||||
closing = true;
|
||||
publishCast();
|
||||
if (outcome == null) outcome = "interrupted";
|
||||
var resources = List.copyOf(owned); owned.clear();
|
||||
for (var resource : resources) try { resource.close(); } catch (Exception failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
|
||||
finished = true;
|
||||
for (var application : List.copyOf(observedEffects.keySet())) effectEnded(application, outcome);
|
||||
if (castEvents != null) castEvents.resolved(audit, outcome,
|
||||
outcome.equals("completed") ? "effect_finished_without_hit_claim" : "effect_ended", resolutionEvidence);
|
||||
|
||||
@@ -17,6 +17,8 @@ public interface AbilityExecution extends AutoCloseable {
|
||||
ControlInput input();
|
||||
games.dmg.fantasystick.persistence.CharacterRecord character();
|
||||
boolean stillCurrent();
|
||||
/** Native-thread clock shared with cast and effect audit timestamps. */
|
||||
default java.time.Instant now() { return java.time.Instant.now(); }
|
||||
List<Entity> eligible(Collection<? extends Entity> candidates, int maximum);
|
||||
AbilityScheduler.Task later(Duration delay, Runnable action);
|
||||
void own(AutoCloseable resource);
|
||||
@@ -40,12 +42,30 @@ public interface AbilityExecution extends AutoCloseable {
|
||||
void acceptedPrevention(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
|
||||
org.bukkit.entity.Player ally, Entity attacker, double incoming,
|
||||
double actual, double remaining, UUID bondApplication);
|
||||
/** Record only the winning owned outgoing reduction after final uncancelled native damage. */
|
||||
default void acceptedOutgoingMitigation(UUID application, Entity debuffedAttacker, Entity protectedTarget,
|
||||
double incoming, double remaining, double actual) { }
|
||||
/** Record only observed native consumption of this execution's owned temporary health. */
|
||||
default void acceptedAbsorption(UUID application, org.bukkit.entity.Player target, Entity attacker,
|
||||
double incoming, double before, double after, double consumed,
|
||||
double remaining, org.bukkit.Location targetAtHit,
|
||||
org.bukkit.Location attackerAtHit) { }
|
||||
/** Record a winning outgoing bonus only after a positive uncancelled native hit. */
|
||||
void acceptedBoost(games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
|
||||
org.bukkit.entity.Player attacker, Entity target,
|
||||
double base, double bonus, double finalDamage, java.math.BigDecimal percent);
|
||||
/** End an owned observed effect once; ordinary close ends any remaining observed effects. */
|
||||
void effectEnded(UUID applicationId, String reason);
|
||||
/** End an observed effect with actual native cleanup/restoration evidence. */
|
||||
default void effectEnded(UUID applicationId, String reason, Map<String, Object> evidence) {
|
||||
effectEnded(applicationId, reason);
|
||||
}
|
||||
/** Capture remaining owned effect state during resource cleanup for the terminal effect record. */
|
||||
default void effectEndEvidence(UUID applicationId, Map<String, Object> evidence) { }
|
||||
/** Attach an observed native admission to the one pending cast record; never publish guessed velocity. */
|
||||
default void castEvidence(Map<String, Object> evidence) { }
|
||||
/** Attach actual final native position to an already chosen or interrupted resolution during cleanup. */
|
||||
default void resolutionEvidence(Map<String, Object> evidence) { }
|
||||
/** An owner may specify a truthful terminal outcome before finishing. */
|
||||
void resolve(String outcome);
|
||||
void resolve(String outcome, Map<String, Object> evidence);
|
||||
|
||||
@@ -19,9 +19,9 @@ import org.bukkit.event.entity.EntityDamageEvent;
|
||||
public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
private final class Grant implements AutoCloseable {
|
||||
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current;
|
||||
final java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation; boolean closed;
|
||||
final java.util.function.Consumer<Accepted> acceptedObservation; boolean closed;
|
||||
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
|
||||
java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation) {
|
||||
java.util.function.Consumer<Accepted> acceptedObservation) {
|
||||
this.execution = execution; this.player = player; this.percent = percent; this.current = current;
|
||||
this.acceptedObservation = acceptedObservation;
|
||||
}
|
||||
@@ -35,7 +35,8 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(player.getUniqueId()); }
|
||||
}
|
||||
}
|
||||
private record Prevention(Grant grant, BigDecimal amount) { }
|
||||
public record Accepted(EntityDamageByEntityEvent event, double incoming, double remaining, double prevented) { }
|
||||
private record Prevention(Grant grant, double incoming, double reduced) { }
|
||||
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
|
||||
private final Map<EntityDamageEvent, Prevention> pending = new IdentityHashMap<>();
|
||||
private boolean closed;
|
||||
@@ -48,9 +49,16 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
return grant(execution, player, percent, current, null);
|
||||
}
|
||||
|
||||
/** An owner may observe only the selected, positive native prevention after final cancellation. */
|
||||
/** Legacy owner observation; keep existing prevention consumers unchanged. */
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
|
||||
java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation) {
|
||||
java.util.function.BiConsumer<EntityDamageEvent, Double> observation) {
|
||||
return grantAccepted(execution, player, percent, current,
|
||||
observation == null ? null : accepted -> observation.accept(accepted.event(), accepted.prevented()));
|
||||
}
|
||||
|
||||
/** An owner may observe the selected, positive native prevention and its original hit value. */
|
||||
public AutoCloseable grantAccepted(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
|
||||
java.util.function.Consumer<Accepted> acceptedObservation) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent); Objects.requireNonNull(current);
|
||||
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
|
||||
throw new IllegalArgumentException("Invalid combat mitigation grant");
|
||||
@@ -75,8 +83,7 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
.multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
|
||||
if (!Double.isFinite(reduced) || reduced < 0) return;
|
||||
event.setDamage(reduced);
|
||||
BigDecimal prevented = BigDecimal.valueOf(original - reduced);
|
||||
if (prevented.signum() > 0) pending.put(event, new Prevention(strongest, prevented));
|
||||
if (original > reduced) pending.put(event, new Prevention(strongest, original, reduced));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
@@ -84,9 +91,15 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
var prevention = pending.remove(event);
|
||||
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !prevention.grant.valid()) return;
|
||||
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
|
||||
double remaining = event.getDamage();
|
||||
double actual = Math.min(prevention.incoming - prevention.reduced, prevention.incoming - remaining);
|
||||
if (!Double.isFinite(remaining) || !Double.isFinite(actual) || actual <= 0) return;
|
||||
try { prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(),
|
||||
BigDecimal.valueOf(actual)); }
|
||||
catch (RuntimeException ignored) { /* Feedback cannot interrupt an accepted native hit or its audit. */ }
|
||||
if (prevention.grant.acceptedObservation != null) try {
|
||||
prevention.grant.acceptedObservation.accept(event, prevention.amount.doubleValue());
|
||||
prevention.grant.acceptedObservation.accept(new Accepted((EntityDamageByEntityEvent) event,
|
||||
prevention.incoming, remaining, actual));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot alter the accepted native hit. */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -58,7 +59,9 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
final BigInteger intelligence;
|
||||
final Settings settings;
|
||||
LivingEntity target;
|
||||
double requested;
|
||||
boolean accepted;
|
||||
boolean nativeCancelled;
|
||||
boolean finished;
|
||||
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
|
||||
this.projectile = projectile; this.execution = execution; this.origin = origin;
|
||||
@@ -74,13 +77,33 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
private final LivingEntity target;
|
||||
private final PotionEffect previous;
|
||||
private final PotionEffect applied;
|
||||
private final AbilityExecution execution;
|
||||
private final long started = System.nanoTime();
|
||||
private UUID application;
|
||||
private String endReason = "interrupted";
|
||||
private boolean owned;
|
||||
private boolean closed;
|
||||
SlowLease(LivingEntity target, PotionEffect applied) {
|
||||
SlowLease(LivingEntity target, PotionEffect applied, AbilityExecution execution) {
|
||||
this.target = target; this.previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
|
||||
this.execution = execution;
|
||||
}
|
||||
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
|
||||
boolean apply() {
|
||||
owned = target.addPotionEffect(applied);
|
||||
if (owned) try {
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("amplifier", applied.getAmplifier());
|
||||
evidence.put("duration_ticks", applied.getDuration());
|
||||
evidence.put("deadline", java.time.Instant.now().plusMillis(applied.getDuration() * 50L).toString());
|
||||
evidence.put("prior_layer", previous == null ? "none" : "replaced");
|
||||
if (previous != null) {
|
||||
evidence.put("prior_amplifier", previous.getAmplifier());
|
||||
evidence.put("prior_duration_ticks", previous.getDuration());
|
||||
}
|
||||
application = execution.effectApplied("frost_shard_slowness", target, evidence);
|
||||
} catch (RuntimeException ignored) { /* Native effect authority is independent of auditing. */ }
|
||||
return owned;
|
||||
}
|
||||
void expire() { endReason = "expired"; close(); }
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
@@ -88,13 +111,24 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
|
||||
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
|
||||
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
|
||||
if (current == null || current.getAmplifier() != applied.getAmplifier()
|
||||
|| current.getDuration() > expected + 2) return;
|
||||
if (current == null) {
|
||||
end(endReason.equals("expired") ? "expired" : "native_layer_missing", "none"); return;
|
||||
}
|
||||
if (current.getAmplifier() != applied.getAmplifier()
|
||||
|| current.getDuration() > expected + 2 || current.getDuration() < expected - 2) {
|
||||
end("superseded", "preserved_newer"); return;
|
||||
}
|
||||
target.removePotionEffect(PotionEffectType.SLOWNESS);
|
||||
String restored = "none";
|
||||
if (previous != null) {
|
||||
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
|
||||
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
|
||||
if (remaining > 0)
|
||||
restored = target.addPotionEffect(previous.withDuration(remaining)) ? "prior" : "refused";
|
||||
}
|
||||
end(endReason, restored);
|
||||
}
|
||||
private void end(String reason, String restored) {
|
||||
if (application != null) execution.effectEnded(application, reason, Map.of("restored_layer", restored));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +164,18 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
|
||||
}
|
||||
|
||||
private static Settings captured(AbilityDefinition definition) {
|
||||
var values = definition.parameters();
|
||||
return new Settings(values.get("base-damage").toBigIntegerExact(), definition.cooldown(),
|
||||
values.get("range").doubleValue(), duration(values.get("lifetime")),
|
||||
values.get("projectile-speed").doubleValue(), values.get("slowness-amplifier").intValueExact(),
|
||||
duration(values.get("slowness-duration")));
|
||||
}
|
||||
|
||||
private static Duration duration(BigDecimal seconds) {
|
||||
return Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
|
||||
}
|
||||
|
||||
public static BigInteger damage(BigInteger intelligence) {
|
||||
return damage(BigInteger.valueOf(4), intelligence);
|
||||
}
|
||||
@@ -146,7 +192,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = settings;
|
||||
var current = captured(execution.definition());
|
||||
var player = execution.input().player();
|
||||
Location origin = player.getEyeLocation().clone();
|
||||
Vector direction = origin.getDirection();
|
||||
@@ -161,12 +207,15 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
}
|
||||
execution.own(state);
|
||||
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
|
||||
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
|
||||
});
|
||||
execution.later(current.lifetime(), () -> {
|
||||
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
|
||||
});
|
||||
Runnable expire = () -> {
|
||||
if (projectiles.containsKey(projectile.getUniqueId())) {
|
||||
try { execution.resolve("expired", Map.of("projectile_id", projectile.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Audit capture cannot retain an expired Arrow. */ }
|
||||
execution.finish();
|
||||
}
|
||||
};
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), expire);
|
||||
execution.later(current.lifetime(), expire);
|
||||
particles.world(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
|
||||
}
|
||||
|
||||
@@ -183,25 +232,38 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
if (state == null) return;
|
||||
boolean retainExecution = false;
|
||||
try {
|
||||
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|
||||
boolean currentSession = !closed && state.execution.stillCurrent();
|
||||
if (!currentSession || event.isCancelled()
|
||||
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) {
|
||||
try { state.execution.resolve(!currentSession ? "interrupted" : event.isCancelled() ? "cancelled" : "miss",
|
||||
Map.of("projectile_id", state.projectile.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Audit capture cannot retain an invalid hit. */ }
|
||||
return;
|
||||
}
|
||||
var current = state.settings;
|
||||
BigInteger amount = damage(current.baseDamage(), state.intelligence);
|
||||
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
|
||||
var source = DamageSource.builder(DamageType.FREEZE).withCausingEntity(state.execution.input().player())
|
||||
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
|
||||
state.target = target; damaging = state;
|
||||
state.target = target; state.requested = nativeDamage; damaging = state;
|
||||
try { state.execution.damage(target, nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
try { state.execution.resolve(state.accepted ? "hit" : state.nativeCancelled ? "cancelled" : "miss",
|
||||
Map.of("projectile_id", state.projectile.getUniqueId().toString(),
|
||||
"target_entity_id", target.getUniqueId().toString(),
|
||||
"impact_location", locationData(state.projectile.getLocation()))); }
|
||||
catch (RuntimeException ignored) { /* Capturing evidence cannot prevent native Slowness. */ }
|
||||
if (state.accepted && state.execution.stillCurrent()) {
|
||||
int ticks = Math.toIntExact(Math.max(1,
|
||||
(current.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
|
||||
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
|
||||
current.slownessAmplifier(), false, true, true));
|
||||
current.slownessAmplifier(), false, true, true), state.execution);
|
||||
if (lease.apply()) {
|
||||
state.execution.own(lease);
|
||||
state.execution.later(current.slownessDuration(), state.execution::finish);
|
||||
state.execution.later(current.slownessDuration(), () -> {
|
||||
lease.expire(); state.execution.finish();
|
||||
});
|
||||
retainExecution = true;
|
||||
}
|
||||
}
|
||||
@@ -217,18 +279,38 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage())) return;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target) return;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|
||||
|| byEntity.getDamager() != state.projectile)) return;
|
||||
if (event.isCancelled()) { state.nativeCancelled = true; return; }
|
||||
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
state.accepted = true;
|
||||
try { state.execution.acceptedDamage(state.target, state.requested, event.getFinalDamage(), state.projectile,
|
||||
Map.of("projectile_id", state.projectile.getUniqueId().toString(),
|
||||
"impact_location", locationData(state.projectile.getLocation()))); }
|
||||
catch (RuntimeException ignored) { /* An audit failure cannot remove an accepted combat contribution. */ }
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || event.getEntity() != state.target) return false;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
return direct == state.projectile || event instanceof EntityDamageByEntityEvent by
|
||||
&& by.getDamager() == state.projectile;
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
if (location == null || location.getWorld() == null) return Map.of("status", "unavailable");
|
||||
return Map.of("world_id", location.getWorld().getUID().toString(),
|
||||
"world_name", location.getWorld().getName(), "x", location.getX(), "y", location.getY(),
|
||||
"z", location.getZ(), "captured_at", java.time.Instant.now().toString());
|
||||
}
|
||||
|
||||
private static String level(int amplifier) {
|
||||
return switch (amplifier + 1) {
|
||||
case 1 -> "I"; case 2 -> "II"; case 3 -> "III"; case 4 -> "IV"; case 5 -> "V";
|
||||
|
||||
@@ -33,18 +33,25 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution;
|
||||
final LivingEntity target;
|
||||
final double requested;
|
||||
final BigDecimal full;
|
||||
final float charge;
|
||||
boolean reported;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) {
|
||||
boolean cancelled;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal full, float charge) {
|
||||
this.execution = execution; this.target = target;
|
||||
this.requested = requested; this.full = full; this.charge = charge;
|
||||
}
|
||||
}
|
||||
|
||||
private final Settings settings;
|
||||
private Settings settings;
|
||||
private Attempt damaging;
|
||||
private boolean closed;
|
||||
|
||||
public GreatSword(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
return new AbilityDefinition("great_sword", "Great Sword", Material.WOODEN_SWORD,
|
||||
List.of("Charge a contained melee strike.",
|
||||
@@ -81,8 +88,11 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
|
||||
BigDecimal amount = damage(settings.baseDamage(), execution.character().stats().get(Stat.STR),
|
||||
execution.input().attackStrength());
|
||||
BigInteger strength = execution.character().stats().get(Stat.STR);
|
||||
BigInteger baseDamage = execution.definition().parameters().get("base-damage").toBigIntegerExact();
|
||||
float charge = execution.input().attackStrength();
|
||||
BigDecimal full = damage(baseDamage, strength, 1.0f);
|
||||
BigDecimal amount = damage(baseDamage, strength, charge);
|
||||
double nativeDamage = amount.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|
||||
? Double.MAX_VALUE : amount.doubleValue();
|
||||
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
|
||||
@@ -91,23 +101,40 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
|
||||
var player = execution.input().player();
|
||||
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player)
|
||||
.withDirectEntity(player).build();
|
||||
var attempt = new Attempt(execution, target);
|
||||
var attempt = new Attempt(execution, target, nativeDamage, full, charge);
|
||||
damaging = attempt;
|
||||
try { execution.damage(target, nativeDamage, source); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
try {
|
||||
execution.damage(target, nativeDamage, source);
|
||||
execution.resolve(attempt.reported ? "hit" : attempt.cancelled ? "cancelled" : "miss",
|
||||
Map.of("requested_amount", BigDecimal.valueOf(nativeDamage).toPlainString(),
|
||||
"full_amount", full.toPlainString(), "charge", Float.toString(charge)));
|
||||
} finally { if (damaging == attempt) damaging = null; }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
/** The generic incoming-player adapter must yield to this contained strike's effect owner. */
|
||||
public boolean ownsAcceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
|
||||
return event instanceof EntityDamageByEntityEvent by && matches(damaging, by);
|
||||
}
|
||||
|
||||
private boolean matches(Attempt attempt, EntityDamageByEntityEvent event) {
|
||||
if (closed || attempt == null || event.getDamager() != attempt.execution.input().player()
|
||||
|| event.getEntity() != attempt.target) return false;
|
||||
var source = event.getDamageSource();
|
||||
return (source.getCausingEntity() == null || source.getCausingEntity() == attempt.execution.input().player())
|
||||
&& (source.getDirectEntity() == null || source.getDirectEntity() == attempt.execution.input().player());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = damaging;
|
||||
if (closed || attempt == null || attempt.reported || event.isCancelled()
|
||||
|| event.getDamager() != attempt.execution.input().player() || event.getEntity() != attempt.target
|
||||
|| (event.getDamageSource().getCausingEntity() != null
|
||||
&& event.getDamageSource().getCausingEntity() != attempt.execution.input().player())
|
||||
|| (event.getDamageSource().getDirectEntity() != null
|
||||
&& event.getDamageSource().getDirectEntity() != attempt.execution.input().player())
|
||||
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
if (attempt == null || attempt.reported || !matches(attempt, event)) return;
|
||||
if (event.isCancelled()) { attempt.cancelled = true; return; }
|
||||
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.reported = true;
|
||||
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
|
||||
attempt.execution.input().player(), Map.of("charge", Float.toString(attempt.charge),
|
||||
"full_amount", attempt.full.toPlainString(),
|
||||
"scaled_requested_amount", BigDecimal.valueOf(attempt.requested).toPlainString()));
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
|
||||
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
|
||||
}
|
||||
|
||||
@@ -35,15 +35,37 @@ public final class Hex implements AbilityEffect,AutoCloseable{
|
||||
"duration-seconds",BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),"damage-reduction-percent",current.reductionPercent()),
|
||||
AbilityDefinition.TargetPolicy.HOSTILE,this);}
|
||||
@Override public boolean accepts(ControlInput input){return !closed&&input.target() instanceof LivingEntity target&&inRange(input,target,settings.range());}
|
||||
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof LivingEntity target))return;
|
||||
var current=from(execution.definition());if(!inRange(execution.input(),target,current.range())||execution.trainingDummy(target)){execution.finish();return;}
|
||||
var state=new State(execution,target,mitigation.grant(execution,target,current.reductionPercent()));var previous=hexes.put(target.getUniqueId(),state);
|
||||
if(previous!=null)previous.execution.finish();execution.own(state);execution.later(current.duration(),execution::finish);}
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
|
||||
var current = from(execution.definition());
|
||||
if (!inRange(execution.input(), target, current.range()) || execution.trainingDummy(target)) {
|
||||
execution.resolve("miss", Map.of("reason", "target_ineligible_after_cooldown"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
var application = new java.util.concurrent.atomic.AtomicReference<UUID>();
|
||||
var grant = mitigation.grantObserved(execution, target, current.reductionPercent(), accepted -> {
|
||||
try { execution.acceptedOutgoingMitigation(application.get(), accepted.debuffedAttacker(),
|
||||
accepted.recipient(), accepted.incoming(), accepted.remaining(), accepted.prevented()); }
|
||||
catch (RuntimeException ignored) { /* Native damage and support do not depend on auditing. */ }
|
||||
});
|
||||
var state = new State(execution, target, grant);
|
||||
var previous = hexes.put(target.getUniqueId(), state);
|
||||
if (previous != null) { previous.execution.resolve("replaced"); previous.execution.finish(); }
|
||||
execution.own(state);
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("reduction_percent", current.reductionPercent().stripTrailingZeros().toPlainString());
|
||||
evidence.put("duration_seconds", seconds(current.duration()));
|
||||
var activatedAt = execution.now();
|
||||
if (activatedAt != null) evidence.put("effect_until", activatedAt.plus(current.duration()).toString());
|
||||
application.set(execution.effectApplied("hex", target, evidence));
|
||||
execution.later(current.duration(), () -> { execution.resolve("expired"); execution.finish(); });
|
||||
}
|
||||
private static boolean inRange(ControlInput input,LivingEntity target,double range){try{Location source=input.player().getLocation(),destination=target.getLocation();
|
||||
return source.getWorld()==destination.getWorld()&&source.distanceSquared(destination)<=range*range&&input.player().hasLineOfSight(target);
|
||||
}catch(RuntimeException invalid){return false;}}
|
||||
private static Settings from(AbilityDefinition definition){var p=definition.parameters();return new Settings(definition.cooldown(),p.get("range").doubleValue(),
|
||||
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-reduction-percent"));}
|
||||
private static String seconds(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
|
||||
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values()))state.execution.finish();hexes.clear();}
|
||||
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values())){
|
||||
state.execution.resolve("interrupted"); state.execution.finish();}hexes.clear();}
|
||||
}
|
||||
@@ -8,7 +8,38 @@ public final class Inspire implements AbilityEffect,AutoCloseable{
|
||||
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Category.BUF,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
|
||||
@Override public boolean accepts(ControlInput input){if(closed||!(input.target() instanceof Player target))return false;if(input.player().isSneaking())return target==input.player();return target!=input.player()&&inRange(input.player(),target,settings.range());}
|
||||
public static BigInteger amount(BigInteger charisma){return amount(BigInteger.valueOf(4),charisma);}static BigInteger amount(BigInteger base,BigInteger charisma){Objects.requireNonNull(base);Objects.requireNonNull(charisma);if(base.signum()<0||charisma.signum()<0)throw new IllegalArgumentException("Invalid Inspire amount");var delta=charisma.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&÷d[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
|
||||
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;var s=from(execution.definition());if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){execution.finish();return;}var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.finish();return;}execution.own(temporaryHealth.grant(execution,target,nativeAmount));execution.later(s.duration(),execution::finish);}
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;
|
||||
var s=from(execution.definition());
|
||||
if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){
|
||||
execution.resolve("miss",Map.of("reason","target_ineligible_after_cooldown"));execution.finish();return;
|
||||
}
|
||||
var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));
|
||||
double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();
|
||||
if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.resolve("miss",Map.of("reason","no_positive_temporary_health"));execution.finish();return;}
|
||||
var application=new java.util.concurrent.atomic.AtomicReference<UUID>();
|
||||
var grant=temporaryHealth.grantObserved(execution,target,nativeAmount,accepted->{
|
||||
try{execution.acceptedAbsorption(application.get(),accepted.target(),accepted.attacker(),accepted.incoming(),
|
||||
accepted.before(),accepted.after(),accepted.consumed(),accepted.remaining(),accepted.targetAtHit(),accepted.attackerAtHit());}
|
||||
catch(RuntimeException ignored){/* Audit cannot change native absorption or party rewards. */}
|
||||
},remaining->{
|
||||
var id=application.get();
|
||||
if(id!=null)execution.effectEndEvidence(id,Map.of("remaining_amount",
|
||||
BigDecimal.valueOf(remaining).stripTrailingZeros().toPlainString()));
|
||||
});
|
||||
execution.own(grant);
|
||||
var evidence=new LinkedHashMap<String,Object>();
|
||||
evidence.put("granted_amount",BigDecimal.valueOf(nativeAmount).stripTrailingZeros().toPlainString());
|
||||
evidence.put("remaining_amount",BigDecimal.valueOf(nativeAmount).stripTrailingZeros().toPlainString());
|
||||
evidence.put("duration_seconds",seconds(s.duration()));
|
||||
var activatedAt=execution.now();if(activatedAt!=null)evidence.put("effect_until",activatedAt.plus(s.duration()).toString());
|
||||
temporaryHealth.currentSession(target).ifPresent(active->{
|
||||
evidence.put("target_character_id",active.character().toString());
|
||||
evidence.put("target_session_id",active.session().toString());
|
||||
});
|
||||
application.set(execution.effectApplied("inspire",target,evidence));
|
||||
execution.later(s.duration(),()->{execution.resolve("expired");execution.finish();});
|
||||
}
|
||||
private static boolean inRange(Player source,Player target,double range){try{var a=source.getLocation();var b=target.getLocation();return a.getWorld()==b.getWorld()&&a.distanceSquared(b)<=range*range&&source.hasLineOfSight(target);}catch(RuntimeException invalid){return false;}}
|
||||
private static Settings from(AbilityDefinition d){var p=d.parameters();return new Settings(p.get("base-temporary-health").toBigIntegerExact(),d.cooldown(),p.get("range").doubleValue(),Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()));}
|
||||
private static String seconds(Duration d){return BigDecimal.valueOf(d.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
|
||||
|
||||
@@ -58,7 +58,9 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
final Settings settings;
|
||||
final BigDecimal damage;
|
||||
LivingEntity target;
|
||||
double requested;
|
||||
boolean accepted;
|
||||
boolean nativeCancelled;
|
||||
boolean finished;
|
||||
State(Arrow arrow, AbilityExecution execution, Location origin, Settings settings, BigDecimal damage) {
|
||||
this.arrow = arrow; this.execution = execution; this.origin = origin; this.settings = settings; this.damage = damage;
|
||||
@@ -141,12 +143,15 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
}
|
||||
execution.own(state);
|
||||
long rangeTicks = Math.max(1, (long) Math.ceil(shot.range() / speed));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
|
||||
});
|
||||
execution.later(shot.lifetime(), () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
|
||||
});
|
||||
Runnable expire = () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) {
|
||||
try { execution.resolve("expired", Map.of("arrow_id", arrow.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot retain expired native arrows. */ }
|
||||
execution.finish();
|
||||
}
|
||||
};
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), expire);
|
||||
execution.later(shot.lifetime(), expire);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
@@ -161,18 +166,32 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
var state = arrows.remove(event.getEntity().getUniqueId());
|
||||
if (state == null) return;
|
||||
try {
|
||||
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|
||||
|| !(event.getHitEntity() instanceof LivingEntity target)
|
||||
boolean currentSession = !closed && state.execution.stillCurrent();
|
||||
if (!currentSession || event.isCancelled() || !(event.getHitEntity() instanceof LivingEntity target)
|
||||
|| !inRange(state, event.getEntity().getLocation())
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) {
|
||||
try { state.execution.resolve(!currentSession ? "interrupted" : event.isCancelled() ? "cancelled" : "miss",
|
||||
Map.of("arrow_id", state.arrow.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot prevent native cleanup. */ }
|
||||
return;
|
||||
}
|
||||
double nativeDamage = state.damage.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|
||||
? Double.MAX_VALUE : state.damage.doubleValue();
|
||||
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) return;
|
||||
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
|
||||
try { state.execution.resolve("miss", Map.of("arrow_id", state.arrow.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot prevent native cleanup. */ }
|
||||
return;
|
||||
}
|
||||
var source = DamageSource.builder(DamageType.ARROW).withCausingEntity(state.execution.input().player())
|
||||
.withDirectEntity(state.arrow).withDamageLocation(state.arrow.getLocation()).build();
|
||||
state.target = target; damaging = state;
|
||||
state.target = target; state.requested = nativeDamage; damaging = state;
|
||||
try { state.execution.damage(target, nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
try { state.execution.resolve(state.accepted ? "hit" : state.nativeCancelled ? "cancelled" : "miss",
|
||||
Map.of("arrow_id", state.arrow.getUniqueId().toString(),
|
||||
"target_entity_id", target.getUniqueId().toString(),
|
||||
"impact_location", locationData(state.arrow.getLocation()))); }
|
||||
catch (RuntimeException ignored) { /* Audit capture cannot change native damage. */ }
|
||||
} finally {
|
||||
state.close(); state.execution.finish();
|
||||
}
|
||||
@@ -184,18 +203,38 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage())) return;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target) return;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct != state.arrow && (!(event instanceof EntityDamageByEntityEvent byEntity)
|
||||
|| byEntity.getDamager() != state.arrow)) return;
|
||||
if (event.isCancelled()) { state.nativeCancelled = true; return; }
|
||||
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
state.accepted = true;
|
||||
try { state.execution.acceptedDamage(state.target, state.requested, event.getFinalDamage(), state.arrow,
|
||||
Map.of("arrow_id", state.arrow.getUniqueId().toString(),
|
||||
"impact_location", locationData(state.arrow.getLocation()))); }
|
||||
catch (RuntimeException ignored) { /* Audit capture cannot change an accepted contribution. */ }
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || event.getEntity() != state.target) return false;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
return direct == state.arrow || event instanceof EntityDamageByEntityEvent by
|
||||
&& by.getDamager() == state.arrow;
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
if (location == null || location.getWorld() == null) return Map.of("status", "unavailable");
|
||||
return Map.of("world_id", location.getWorld().getUID().toString(),
|
||||
"world_name", location.getWorld().getName(), "x", location.getX(), "y", location.getY(),
|
||||
"z", location.getZ(), "captured_at", java.time.Instant.now().toString());
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var p = definition.parameters();
|
||||
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(),
|
||||
|
||||
@@ -25,24 +25,36 @@ import org.bukkit.projectiles.ProjectileSource;
|
||||
public final class OutgoingDamageMitigation implements Listener, AutoCloseable {
|
||||
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) { }
|
||||
private final class Grant implements AutoCloseable {
|
||||
final long sequence; final AbilityExecution execution; final LivingEntity target; final BigDecimal percent; boolean closed;
|
||||
Grant(AbilityExecution execution, LivingEntity target, BigDecimal percent) {
|
||||
final long sequence; final AbilityExecution execution; final LivingEntity target; final BigDecimal percent;
|
||||
final Consumer<Accepted> observation; boolean closed;
|
||||
Grant(AbilityExecution execution, LivingEntity target, BigDecimal percent, Consumer<Accepted> observation) {
|
||||
this.sequence=++sequences;this.execution=execution;this.target=target;this.percent=percent;
|
||||
this.observation = observation;
|
||||
}
|
||||
@Override public void close(){if(closed)return;closed=true;var owned=grants.get(target.getUniqueId());
|
||||
if(owned!=null){owned.remove(this);if(owned.isEmpty())grants.remove(target.getUniqueId());}}
|
||||
}
|
||||
private record Prevention(Grant grant, Entity recipient, BigDecimal amount) { }
|
||||
public record Accepted(LivingEntity debuffedAttacker, Entity recipient,
|
||||
double incoming, double remaining, double prevented) { }
|
||||
private record Prevention(Grant grant, Entity recipient, double incoming, double reduced) { }
|
||||
private final EquipmentSessions sessions; private final PartyRoster parties; private final Consumer<Support> supports;
|
||||
private final Map<UUID,List<Grant>> grants=new java.util.HashMap<>();
|
||||
private final Map<EntityDamageEvent,Prevention> pending=new IdentityHashMap<>();
|
||||
private long sequences; private boolean closed;
|
||||
public OutgoingDamageMitigation(EquipmentSessions sessions,PartyRoster parties,Consumer<Support> supports){
|
||||
this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.supports=Objects.requireNonNull(supports);}
|
||||
public AutoCloseable grant(AbilityExecution execution,LivingEntity target,BigDecimal percent){
|
||||
Objects.requireNonNull(execution);Objects.requireNonNull(target);Objects.requireNonNull(percent);
|
||||
if(closed||percent.signum()<0||percent.compareTo(BigDecimal.valueOf(100))>0)throw new IllegalArgumentException("Invalid outgoing mitigation grant");
|
||||
var grant=new Grant(execution,target,percent);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);return grant;}
|
||||
public AutoCloseable grant(AbilityExecution execution, LivingEntity target, BigDecimal percent) {
|
||||
return grantObserved(execution, target, percent, null);
|
||||
}
|
||||
public AutoCloseable grantObserved(AbilityExecution execution, LivingEntity target, BigDecimal percent,
|
||||
Consumer<Accepted> observation) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(target); Objects.requireNonNull(percent);
|
||||
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
|
||||
throw new IllegalArgumentException("Invalid outgoing mitigation grant");
|
||||
var grant = new Grant(execution, target, percent, observation);
|
||||
grants.computeIfAbsent(target.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
|
||||
return grant;
|
||||
}
|
||||
|
||||
@EventHandler(priority=EventPriority.NORMAL,ignoreCancelled=true)
|
||||
public void reduce(EntityDamageByEntityEvent event){
|
||||
@@ -53,20 +65,31 @@ public final class OutgoingDamageMitigation implements Listener, AutoCloseable {
|
||||
double original=event.getDamage();if(strongest==null||!Double.isFinite(original)||original<=0)return;
|
||||
double reduced=BigDecimal.valueOf(original).multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
|
||||
if(!Double.isFinite(reduced)||reduced<0)return;event.setDamage(reduced);var prevented=BigDecimal.valueOf(original-reduced);
|
||||
if(prevented.signum()>0)pending.put(event,new Prevention(strongest,event.getEntity(),prevented));
|
||||
if(prevented.signum()>0)pending.put(event,new Prevention(strongest,event.getEntity(),original,reduced));
|
||||
}
|
||||
|
||||
@EventHandler(priority=EventPriority.MONITOR)
|
||||
public void accepted(EntityDamageEvent event){
|
||||
var prevention=pending.remove(event);if(prevention==null||event.isCancelled()||!prevention.grant.execution.stillCurrent())return;
|
||||
prevention.grant.execution.report(AbilityReport.Type.PREVENTION,prevention.grant.target,prevention.amount);
|
||||
if(!(prevention.recipient instanceof Player player))return;
|
||||
var prevention = pending.remove(event);
|
||||
if (prevention == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())
|
||||
|| event.getFinalDamage() <= 0 || !prevention.grant.execution.stillCurrent()) return;
|
||||
double remaining = event.getDamage();
|
||||
double actual = Math.min(prevention.incoming - prevention.reduced, prevention.incoming - remaining);
|
||||
if (!Double.isFinite(remaining) || !Double.isFinite(actual) || actual <= 0) return;
|
||||
var amount = BigDecimal.valueOf(actual);
|
||||
try { prevention.grant.execution.report(AbilityReport.Type.PREVENTION, prevention.grant.target, amount); }
|
||||
catch (RuntimeException ignored) { /* Feedback cannot interrupt accepted native prevention. */ }
|
||||
if (prevention.grant.observation != null) try {
|
||||
prevention.grant.observation.accept(new Accepted(prevention.grant.target,
|
||||
prevention.recipient, prevention.incoming, remaining, actual));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot alter damage or rewards. */ }
|
||||
if (!(prevention.recipient instanceof Player player)) return;
|
||||
var aided=sessions.active(player.getUniqueId()).orElse(null);if(aided==null||!sessions.current(aided))return;
|
||||
var input=prevention.grant.execution.input();if(aided.character().equals(input.session().character()))return;
|
||||
var sourceParty=parties.partyOf(input.player().getUniqueId()).orElse(null);
|
||||
var aidedParty=parties.partyOf(player.getUniqueId()).orElse(null);
|
||||
if(sourceParty==null||aidedParty==null||!sourceParty.id().equals(aidedParty.id()))return;
|
||||
supports.accept(new Support(prevention.grant.target.getUniqueId(),input.session().character(),aided,prevention.amount));
|
||||
supports.accept(new Support(prevention.grant.target.getUniqueId(),input.session().character(),aided,amount));
|
||||
}
|
||||
|
||||
private static Entity attacker(EntityDamageByEntityEvent event){
|
||||
|
||||
@@ -41,11 +41,20 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
final Location origin;
|
||||
final Vector horizontal;
|
||||
final Settings settings;
|
||||
final long started = System.nanoTime();
|
||||
final long boundTicks;
|
||||
Location lastObserved;
|
||||
boolean owned = true;
|
||||
boolean closed;
|
||||
State(AbilityExecution execution, Player player, Location origin, Vector horizontal, Settings settings) {
|
||||
State(AbilityExecution execution, Player player, Location origin, Vector horizontal,
|
||||
Settings settings, long boundTicks) {
|
||||
this.execution = execution; this.player = player; this.origin = origin;
|
||||
this.horizontal = horizontal; this.settings = settings;
|
||||
this.horizontal = horizontal; this.settings = settings; this.boundTicks = boundTicks;
|
||||
}
|
||||
void finish(String outcome, Location observed) {
|
||||
lastObserved = observed == null ? null : observed.clone();
|
||||
execution.resolve(outcome);
|
||||
execution.finish();
|
||||
}
|
||||
boolean stillOwns(Vector current) {
|
||||
var horizontalCurrent = new Vector(current.getX(), 0, current.getZ());
|
||||
@@ -57,6 +66,20 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true; dashes.remove(player.getUniqueId(), this);
|
||||
try {
|
||||
var to = lastObserved == null ? player.getLocation() : lastObserved;
|
||||
if (to != null) {
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("to_location", locationData(to));
|
||||
evidence.put("elapsed_ms", Math.max(0, (System.nanoTime() - started) / 1_000_000L));
|
||||
evidence.put("bound_ticks", boundTicks);
|
||||
if (to.getWorld() != null && to.getWorld().equals(origin.getWorld())) {
|
||||
double dx = to.getX() - origin.getX(), dz = to.getZ() - origin.getZ();
|
||||
evidence.put("horizontal_distance", Math.hypot(dx, dz));
|
||||
} else evidence.put("horizontal_distance_status", "not_comparable_cross_world");
|
||||
execution.resolutionEvidence(evidence);
|
||||
}
|
||||
} catch (RuntimeException ignored) { /* Auditing cannot change native motion or cleanup. */ }
|
||||
if (!owned) return;
|
||||
Vector current = player.getVelocity();
|
||||
if (!stillOwns(current)) return;
|
||||
@@ -98,24 +121,41 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = from(execution.definition());
|
||||
var player = execution.input().player();
|
||||
if (!accepts(execution.input())) { execution.finish(); return; }
|
||||
var origin = player.getLocation().clone();
|
||||
try { execution.castEvidence(Map.of("from_location", locationData(origin))); }
|
||||
catch (RuntimeException ignored) { /* Capturing an origin cannot affect admission. */ }
|
||||
if (!accepts(execution.input())) {
|
||||
rejectedVelocity(execution, origin, "native_recheck_refused"); return;
|
||||
}
|
||||
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0).normalize();
|
||||
Vector before = player.getVelocity();
|
||||
Vector horizontal = direction.multiply(current.speedPerTick());
|
||||
Vector horizontal = direction.clone().multiply(current.speedPerTick());
|
||||
var proposed = new Vector(horizontal.getX(), before.getY(), horizontal.getZ());
|
||||
try { execution.castEvidence(Map.of("intended_direction", vectorData(direction),
|
||||
"native_velocity_status", "pending_native_decision")); }
|
||||
catch (RuntimeException ignored) { /* Capturing intent cannot affect native velocity. */ }
|
||||
var event = new PlayerVelocityEvent(player, proposed.clone());
|
||||
velocityEvents.accept(event);
|
||||
if (event.isCancelled() || !finite(event.getVelocity())) { execution.finish(); return; }
|
||||
if (event.isCancelled() || !finite(event.getVelocity())) {
|
||||
rejectedVelocity(execution, origin, "native_velocity_cancelled_or_invalid"); return;
|
||||
}
|
||||
Vector accepted = event.getVelocity().clone(); accepted.setY(before.getY());
|
||||
Vector acceptedHorizontal = new Vector(accepted.getX(), 0, accepted.getZ());
|
||||
if (acceptedHorizontal.lengthSquared() < 1.0e-12) { execution.finish(); return; }
|
||||
var state = new State(execution, player, player.getLocation().clone(), acceptedHorizontal, current);
|
||||
if (acceptedHorizontal.lengthSquared() < 1.0e-12) {
|
||||
rejectedVelocity(execution, origin, "native_velocity_without_horizontal_motion"); return;
|
||||
}
|
||||
try { execution.castEvidence(Map.of("accepted_velocity", vectorData(accepted),
|
||||
"native_velocity_status", "accepted")); }
|
||||
catch (RuntimeException ignored) { /* Capturing accepted velocity cannot affect movement. */ }
|
||||
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
|
||||
var state = new State(execution, player, origin, acceptedHorizontal, current, ticks + 1);
|
||||
var previous = dashes.put(player.getUniqueId(), state);
|
||||
if (previous != null) previous.execution.finish();
|
||||
if (previous != null) previous.finish("replaced", previous.player.getLocation());
|
||||
execution.own(state);
|
||||
player.setVelocity(accepted);
|
||||
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), execution::finish);
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), () -> {
|
||||
execution.resolve("completed", Map.of("reason", "elapsed_bound")); execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@@ -123,18 +163,44 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
var state = dashes.get(event.getPlayer().getUniqueId());
|
||||
if (state == null || state.player != event.getPlayer()) return;
|
||||
var to = event.getTo();
|
||||
if (to == null || !state.execution.stillCurrent()) { state.execution.finish(); return; }
|
||||
if (to == null || !state.execution.stillCurrent()) {
|
||||
state.finish("interrupted", to); return;
|
||||
}
|
||||
state.lastObserved = to.clone();
|
||||
if (to.getWorld() == null || !to.getWorld().equals(state.origin.getWorld())) {
|
||||
state.finish("interrupted", to); return;
|
||||
}
|
||||
Vector current = state.player.getVelocity();
|
||||
if (!state.stillOwns(current)) {
|
||||
state.owned = false; state.execution.finish(); return;
|
||||
state.owned = false; state.finish("replaced", to); return;
|
||||
}
|
||||
double dx = to.getX() - state.origin.getX(); double dz = to.getZ() - state.origin.getZ();
|
||||
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()
|
||||
|| new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
|
||||
state.execution.finish();
|
||||
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()) {
|
||||
state.finish("completed", to);
|
||||
} else if (new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
|
||||
state.finish("obstructed", to);
|
||||
}
|
||||
}
|
||||
|
||||
private static void rejectedVelocity(AbilityExecution execution, Location origin, String reason) {
|
||||
try { execution.castEvidence(Map.of("native_velocity_status", "cancelled"));
|
||||
execution.resolve("cancelled", Map.of("reason", reason,
|
||||
"to_location", locationData(origin), "horizontal_distance", 0)); }
|
||||
catch (RuntimeException ignored) { /* Native rejection is not contingent on audit capture. */ }
|
||||
execution.finish();
|
||||
}
|
||||
|
||||
private static Map<String, Object> vectorData(Vector vector) {
|
||||
return Map.of("x", vector.getX(), "y", vector.getY(), "z", vector.getZ());
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
if (location == null || location.getWorld() == null) return Map.of("status", "unavailable");
|
||||
return Map.of("world_id", location.getWorld().getUID().toString(),
|
||||
"world_name", location.getWorld().getName(), "x", location.getX(), "y", location.getY(),
|
||||
"z", location.getZ(), "captured_at", java.time.Instant.now().toString());
|
||||
}
|
||||
|
||||
private static boolean finite(Vector vector) {
|
||||
return Double.isFinite(vector.getX()) && Double.isFinite(vector.getY()) && Double.isFinite(vector.getZ());
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
private games.dmg.fantasystick.session.DownedController downed;
|
||||
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
|
||||
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
|
||||
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
|
||||
private java.util.function.Supplier<Duration> downedProtection=()->Duration.ofSeconds(5);
|
||||
private final Map<Inventory, View> inventories = new IdentityHashMap<>();
|
||||
private final Map<UUID, View> targets = new LinkedHashMap<>();
|
||||
@@ -93,6 +94,11 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
public void attachDowned(games.dmg.fantasystick.session.DownedController controller,java.util.function.Supplier<Duration> protection){if(closed||downed!=null)throw new IllegalStateException("Downed Revive already attached or closed");downed=Objects.requireNonNull(controller);downedProtection=Objects.requireNonNull(protection);}
|
||||
public void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(closed||downedEvents!=null)throw new IllegalStateException("Downed Revive events already attached or closed");downedEvents=Objects.requireNonNull(events);}
|
||||
public void attachEvents(games.dmg.fantasystick.events.ReviveEventBridge events){if(closed||reviveEvents!=null)throw new IllegalStateException("Revive events already attached or closed");reviveEvents=Objects.requireNonNull(events);}
|
||||
public void attachAdminRevivalEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events){if(closed||adminRevivalEvents!=null)throw new IllegalStateException("Admin revival events already attached or closed");adminRevivalEvents=Objects.requireNonNull(events);}
|
||||
private void recoveryEvent(RevivalStore.Reservation reservation,CharacterRecord restored,Player target){
|
||||
if(reservation.administrative()){if(adminRevivalEvents!=null)adminRevivalEvents.recovered(reservation,restored,target);}
|
||||
else if(reviveEvents!=null)reviveEvents.recovered(reservation,restored,target);
|
||||
}
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
return new AbilityDefinition("revive", "Revive", Material.ECHO_SHARD,
|
||||
@@ -358,7 +364,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
: source != null && source.getAmount() == reservation.sourceAmount() - 1
|
||||
&& tails.identify(source).filter(id -> id.deathId().equals(reservation.death())).isPresent();
|
||||
if (!record.dead() && hasSheet && installFinalSheet(target, record)) {
|
||||
if(reviveEvents!=null)reviveEvents.recovered(reservation,record,target);reservations.clear(reservation.character(), reservation.id()); return;
|
||||
recoveryEvent(reservation,record,target);reservations.clear(reservation.character(), reservation.id()); return;
|
||||
}
|
||||
if (record.dead() && unchangedTail && !hasSheet) {
|
||||
reservations.clear(reservation.character(), reservation.id()); return;
|
||||
@@ -375,7 +381,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
.whenComplete((restored, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) {
|
||||
if(reviveEvents!=null)reviveEvents.recovered(reservation,restored.orElseThrow(),target);reservations.clear(reservation.character(), reservation.id());
|
||||
recoveryEvent(reservation,restored.orElseThrow(),target);reservations.clear(reservation.character(), reservation.id());
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -80,17 +80,24 @@ public final class SecondWind implements AbilityEffect {
|
||||
BigInteger base = definition.parameters().get("base-healing").toBigIntegerExact();
|
||||
var player = execution.input().player();
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) { execution.finish(); return; }
|
||||
if (attribute == null) { execution.resolve("cancelled", Map.of("reason", "maximum_unavailable")); execution.finish(); return; }
|
||||
double current = player.getHealth(); double maximum = attribute.getValue();
|
||||
double missing = maximum - current;
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) {
|
||||
execution.resolve("cancelled", Map.of("reason", "health_unavailable_or_full")); execution.finish(); return;
|
||||
}
|
||||
BigInteger exact = healing(base, execution.character().stats().get(Stat.WIS));
|
||||
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
|
||||
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
|
||||
if (!Double.isFinite(proposed) || proposed <= 0) {
|
||||
execution.resolve("cancelled", Map.of("reason", "healing_nonpositive")); execution.finish(); return;
|
||||
}
|
||||
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event);
|
||||
double accepted = Math.min(missing, event.getAmount());
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {
|
||||
execution.resolve("cancelled", Map.of("reason", "native_healing_cancelled_or_nonpositive"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
var record = execution.character(); var expected = record.revision();
|
||||
BigDecimal target = BigDecimal.valueOf(current + accepted);
|
||||
BigDecimal limit = nativeLimit.get();
|
||||
@@ -99,18 +106,33 @@ public final class SecondWind implements AbilityEffect {
|
||||
nextTick.accept(() -> {
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty() || !execution.stillCurrent()) {
|
||||
execution.resolve(failure != null || saved == null || saved.isEmpty() ? "failed" : "interrupted",
|
||||
Map.of("reason", failure != null ? "health_save_failed" : saved == null || saved.isEmpty()
|
||||
? "health_save_unconfirmed" : "session_changed_before_healing"));
|
||||
player.sendMessage("Second Wind health could not be saved; no healing was applied."); return;
|
||||
}
|
||||
var persisted = saved.orElseThrow();
|
||||
if (!sessions.acknowledgeProgress(execution.input().session(), expected, persisted)) {
|
||||
execution.resolve("interrupted", Map.of("reason", "health_saved_session_changed"));
|
||||
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
|
||||
}
|
||||
double nativeBefore = player.getHealth();
|
||||
double publish = Math.min(attribute.getValue(), Math.max(nativeBefore, persisted.health().doubleValue()));
|
||||
player.setHealth(publish);
|
||||
double restored = publish - nativeBefore;
|
||||
if (Double.isFinite(restored) && restored > 0) execution.report(AbilityReport.Type.HEALING,
|
||||
player, BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
try { player.setHealth(publish); }
|
||||
catch (RuntimeException nativeFailure) {
|
||||
execution.resolve("failed", Map.of("reason", "native_health_publication_failed"));
|
||||
throw nativeFailure;
|
||||
}
|
||||
double nativeAfter = player.getHealth();
|
||||
double restored = nativeAfter - nativeBefore;
|
||||
if (Double.isFinite(restored) && restored > 0) {
|
||||
try { execution.acceptedHeal(execution.input().session(), player, persisted, proposed,
|
||||
restored, nativeBefore, nativeAfter, maximum); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot change an applied native heal. */ }
|
||||
execution.resolve("healed", Map.of("reason", "native_self_heal_applied"));
|
||||
execution.report(AbilityReport.Type.HEALING, player,
|
||||
BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
} else execution.resolve("cancelled", Map.of("reason", "native_heal_not_observed"));
|
||||
} finally { execution.finish(); }
|
||||
});
|
||||
} catch (RuntimeException rejected) { execution.finish(); }
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerVelocityEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
@@ -39,9 +40,12 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; final Settings settings; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, Settings settings) {
|
||||
final AbilityExecution execution; final LivingEntity target; final Settings settings;
|
||||
final double requested; final double healthBefore;
|
||||
double finalDamage; boolean accepted; boolean cancelled;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, Settings settings, double requested, double healthBefore) {
|
||||
this.execution = execution; this.target = target; this.settings = settings;
|
||||
this.requested = requested; this.healthBefore = healthBefore;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,42 +89,109 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
|
||||
var current = from(execution.definition()); var player = execution.input().player();
|
||||
if (!inRange(player, target, current.range())) { execution.finish(); return; }
|
||||
if (!inRange(player, target, current.range())) {
|
||||
execution.resolve("miss", Map.of("reason", "target_out_of_range_or_obstructed"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
var exact = damage(current.baseDamage(), execution.character().stats().get(Stat.STR));
|
||||
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
|
||||
if (!Double.isFinite(amount) || amount <= 0) { execution.finish(); return; }
|
||||
if (!Double.isFinite(amount) || amount <= 0) {
|
||||
execution.resolve("miss", Map.of("reason", "damage_nonpositive")); execution.finish(); return;
|
||||
}
|
||||
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player).withDirectEntity(player).build();
|
||||
var attempt = new Attempt(execution, target, current); damaging = attempt;
|
||||
try { execution.damage(target, amount, source); }
|
||||
finally { if (damaging == attempt) damaging = null; execution.finish(); }
|
||||
double beforeHealth;
|
||||
try { beforeHealth = target.getHealth(); }
|
||||
catch (RuntimeException unavailable) { beforeHealth = Double.NaN; }
|
||||
var attempt = new Attempt(execution, target, current, amount, beforeHealth); damaging = attempt;
|
||||
boolean nativeFailed = false;
|
||||
try {
|
||||
execution.damage(target, amount, source);
|
||||
if (attempt.accepted) publishAccepted(attempt);
|
||||
}
|
||||
catch (RuntimeException failed) {
|
||||
nativeFailed = true;
|
||||
if (!attempt.accepted) execution.resolve("failed", Map.of("reason", "native_damage_failed"));
|
||||
throw failed;
|
||||
} finally {
|
||||
if (damaging == attempt) damaging = null;
|
||||
if (!attempt.accepted && !nativeFailed)
|
||||
execution.resolve(attempt.cancelled ? "cancelled" : "miss",
|
||||
Map.of("reason", attempt.cancelled ? "native_damage_cancelled" : "native_damage_not_accepted"));
|
||||
execution.finish();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
/** Yield generic incoming-player capture to the owning accepted Shield Bash strike. */
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
return event instanceof EntityDamageByEntityEvent by && matches(damaging, by);
|
||||
}
|
||||
|
||||
private boolean matches(Attempt attempt, EntityDamageByEntityEvent event) {
|
||||
if (closed || attempt == null || event.getDamager() != attempt.execution.input().player()
|
||||
|| event.getEntity() != attempt.target) return false;
|
||||
var source = event.getDamageSource();
|
||||
return (source.getCausingEntity() == null || source.getCausingEntity() == attempt.execution.input().player())
|
||||
&& (source.getDirectEntity() == null || source.getDirectEntity() == attempt.execution.input().player());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = damaging;
|
||||
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.execution.input().player()
|
||||
|| event.getEntity() != attempt.target || !attempt.execution.stillCurrent()) return;
|
||||
if (!matches(attempt, event) || attempt.accepted || !attempt.execution.stillCurrent()) return;
|
||||
if (event.isCancelled()) { attempt.cancelled = true; return; }
|
||||
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
|
||||
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
|
||||
knockback(attempt);
|
||||
attempt.finalDamage = event.getFinalDamage();
|
||||
}
|
||||
|
||||
private void knockback(Attempt attempt) {
|
||||
var player = attempt.execution.input().player(); var target = attempt.target;
|
||||
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
|
||||
if (!finite(away) || away.lengthSquared() < 1.0e-12) return;
|
||||
Vector current = target.getVelocity();
|
||||
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
|
||||
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
|
||||
if (!finite(proposed)) return;
|
||||
if (target instanceof Player targetPlayer) {
|
||||
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
|
||||
if (event.isCancelled() || !finite(event.getVelocity())) return;
|
||||
proposed = event.getVelocity().clone();
|
||||
}
|
||||
target.setVelocity(proposed);
|
||||
private void publishAccepted(Attempt attempt) {
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("stage", "native_damage_accepted");
|
||||
try {
|
||||
double afterHealth = attempt.target.getHealth();
|
||||
if (Double.isFinite(attempt.healthBefore) && attempt.healthBefore > 0
|
||||
&& Double.isFinite(afterHealth) && afterHealth >= 0) {
|
||||
evidence.put("target_health_before", BigDecimal.valueOf(attempt.healthBefore).stripTrailingZeros().toPlainString());
|
||||
evidence.put("target_health_after", BigDecimal.valueOf(afterHealth).stripTrailingZeros().toPlainString());
|
||||
}
|
||||
} catch (RuntimeException ignored) { /* Target health may be unavailable after a native hit. */ }
|
||||
try { attempt.execution.acceptedDamage(attempt.target, attempt.requested, attempt.finalDamage,
|
||||
attempt.execution.input().player(), evidence); }
|
||||
catch (RuntimeException ignored) { /* File auditing cannot interrupt accepted native damage. */ }
|
||||
try { attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target,
|
||||
BigDecimal.valueOf(attempt.finalDamage).stripTrailingZeros()); }
|
||||
catch (RuntimeException ignored) { /* Feedback cannot alter accepted damage or knockback. */ }
|
||||
String knockbackStatus = knockback(attempt);
|
||||
attempt.execution.resolve("hit", Map.of("reason", "native_damage_accepted",
|
||||
"knockback_status", knockbackStatus));
|
||||
}
|
||||
|
||||
private String knockback(Attempt attempt) {
|
||||
if (!attempt.execution.stillCurrent()) return "session_changed_after_damage";
|
||||
try {
|
||||
var player = attempt.execution.input().player(); var target = attempt.target;
|
||||
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
|
||||
if (!finite(away) || away.lengthSquared() < 1.0e-12) return "direction_unavailable";
|
||||
Vector current = target.getVelocity();
|
||||
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
|
||||
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
|
||||
if (!finite(proposed)) return "velocity_invalid";
|
||||
if (target instanceof Player targetPlayer) {
|
||||
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
|
||||
if (event.isCancelled()) return "native_velocity_cancelled";
|
||||
if (!finite(event.getVelocity())) return "velocity_invalid";
|
||||
proposed = event.getVelocity().clone();
|
||||
}
|
||||
target.setVelocity(proposed);
|
||||
try { attempt.execution.effectApplied("shield_bash_knockback", target,
|
||||
Map.of("knockback_horizontal", decimal(attempt.settings.horizontalKnockback()),
|
||||
"accepted_velocity_x", decimal(proposed.getX()),
|
||||
"accepted_velocity_y", decimal(proposed.getY()),
|
||||
"accepted_velocity_z", decimal(proposed.getZ()),
|
||||
"status", "native_velocity_accepted")); }
|
||||
catch (RuntimeException ignored) { /* Accepted movement does not depend on auditing. */ }
|
||||
return "native_velocity_accepted";
|
||||
} catch (RuntimeException invalid) { return "native_velocity_failed"; }
|
||||
}
|
||||
|
||||
private static boolean inRange(Player player, LivingEntity target, double range) {
|
||||
|
||||
@@ -62,11 +62,24 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = from(execution.definition()); var player = execution.input().player();
|
||||
var grant = mitigation.grant(execution, player, current.reductionPercent());
|
||||
var grant = mitigation.grantAccepted(execution, player, current.reductionPercent(), () -> true,
|
||||
accepted -> {
|
||||
try {
|
||||
execution.acceptedPrevention(execution.input().session(), player, accepted.event().getDamager(),
|
||||
accepted.incoming(), accepted.prevented(), accepted.remaining(), null);
|
||||
} catch (RuntimeException ignored) { /* Auditing cannot change an accepted native hit. */ }
|
||||
});
|
||||
var state = new State(execution, player, grant);
|
||||
var previous = protections.put(player.getUniqueId(), state);
|
||||
if (previous != null) previous.execution.finish();
|
||||
execution.own(state); execution.later(current.duration(), execution::finish);
|
||||
if (previous != null) { previous.execution.resolve("replaced"); previous.execution.finish(); }
|
||||
execution.own(state);
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("reduction_percent", current.reductionPercent().stripTrailingZeros().toPlainString());
|
||||
evidence.put("duration_seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString());
|
||||
var activatedAt = execution.now();
|
||||
if (activatedAt != null) evidence.put("effect_until", activatedAt.plus(current.duration()).toString());
|
||||
execution.effectApplied("stone_skin", player, evidence);
|
||||
execution.later(current.duration(), () -> { execution.resolve("expired"); execution.finish(); });
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
@@ -78,7 +91,9 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
for (var state : List.copyOf(protections.values())) state.execution.finish();
|
||||
for (var state : List.copyOf(protections.values())) {
|
||||
state.execution.resolve("interrupted"); state.execution.finish();
|
||||
}
|
||||
protections.clear();
|
||||
}
|
||||
}
|
||||
@@ -13,21 +13,83 @@ import org.bukkit.projectiles.ProjectileSource;
|
||||
/** Plugin-owned temporary-health ledger layered over unrelated native absorption. */
|
||||
public final class TemporaryHealth implements Listener,AutoCloseable{
|
||||
public record Support(UUID target,UUID supportingCharacter,EquipmentSessions.Active aided,BigDecimal amount){}
|
||||
private final class Grant implements AutoCloseable{final long sequence;final AbilityExecution execution;final Player target;double remaining;boolean closed;
|
||||
Grant(AbilityExecution e,Player t,double amount){sequence=++sequences;execution=e;target=t;remaining=amount;}
|
||||
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));}}
|
||||
public record Accepted(Player target,Entity attacker,double incoming,double before,double after,
|
||||
double consumed,double remaining,org.bukkit.Location targetAtHit,
|
||||
org.bukkit.Location attackerAtHit){}
|
||||
private final class Grant implements AutoCloseable{final long sequence;final AbilityExecution execution;final Player target;
|
||||
final Consumer<Accepted> observation;final Consumer<Double> onEnd;double remaining;boolean closed;
|
||||
Grant(AbilityExecution e,Player t,double amount,Consumer<Accepted> observation,Consumer<Double> onEnd){
|
||||
sequence=++sequences;execution=e;target=t;remaining=amount;this.observation=observation;this.onEnd=onEnd;}
|
||||
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));if(onEnd!=null)try{onEnd.accept(Math.max(0,remaining));}catch(RuntimeException ignored){/* Cleanup is independent of recording. */}}
|
||||
}
|
||||
private final EquipmentSessions sessions;private final PartyRoster parties;private final Consumer<Runnable> nextTick;private final Consumer<Support> supports;
|
||||
private final Map<UUID,List<Grant>> grants=new HashMap<>();private long sequences;private boolean closed;
|
||||
private final Map<UUID,List<Grant>> grants=new HashMap<>();
|
||||
private final Set<EntityDamageEvent> pending=Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private long sequences;private boolean closed;
|
||||
public TemporaryHealth(EquipmentSessions sessions,PartyRoster parties,Consumer<Runnable> nextTick,Consumer<Support> supports){this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.nextTick=Objects.requireNonNull(nextTick);this.supports=Objects.requireNonNull(supports);}
|
||||
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){Objects.requireNonNull(execution);Objects.requireNonNull(target);if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);double after=owned(target.getUniqueId());addNative(target,Math.max(0,after-before));return grant;}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(closed||!(event.getEntity() instanceof Player target))return;var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());if(!Double.isFinite(before)||before<=0||owned<=0)return;Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;var aided=sessions.active(target.getUniqueId()).orElse(null);boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);UUID enemy=attacker==null?null:attacker.getUniqueId();
|
||||
try{nextTick.accept(()->consume(target,before,owned,owner,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
|
||||
private void consume(Player target,double before,double ownedBefore,Grant owner,EquipmentSessions.Active aided,UUID enemy){if(closed)return;double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));if(!Double.isFinite(consumed)||consumed<=0)return;var list=grants.get(target.getUniqueId());if(list==null)return;var amount=BigDecimal.valueOf(consumed).stripTrailingZeros();owner.execution.report(AbilityReport.Type.PREVENTION,target,amount);for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,owner.execution.input().session().character(),aided,amount));}
|
||||
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){
|
||||
return grantObserved(execution,target,amount,null);
|
||||
}
|
||||
public AutoCloseable grantObserved(AbilityExecution execution,Player target,double amount,Consumer<Accepted> observation){
|
||||
return grantObserved(execution,target,amount,observation,null);
|
||||
}
|
||||
public AutoCloseable grantObserved(AbilityExecution execution,Player target,double amount,
|
||||
Consumer<Accepted> observation,Consumer<Double> onEnd){
|
||||
Objects.requireNonNull(execution);Objects.requireNonNull(target);
|
||||
if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");
|
||||
double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount,observation,onEnd);
|
||||
addNative(target,Math.max(0,amount-before)); // Do not retain an owned grant if the native setter refuses it.
|
||||
grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);
|
||||
return grant;
|
||||
}
|
||||
public Optional<EquipmentSessions.Active> currentSession(Player target){
|
||||
return sessions.active(target.getUniqueId()).filter(sessions::current);
|
||||
}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){
|
||||
if(closed||pending.contains(event)||!(event.getEntity() instanceof Player target)
|
||||
||!Double.isFinite(event.getDamage())||event.getDamage()<=0)return;
|
||||
var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;
|
||||
double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());
|
||||
if(!Double.isFinite(before)||before<=0||owned<=0)return;
|
||||
Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;
|
||||
var targetAtHit=auditLocation(target);var attackerAtHit=auditLocation(attacker);
|
||||
var aided=sessions.active(target.getUniqueId()).orElse(null);
|
||||
boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);
|
||||
UUID enemy=attacker==null?null:attacker.getUniqueId();double incoming=event.getDamage();
|
||||
pending.add(event);
|
||||
try{nextTick.accept(()->{
|
||||
try{consume(event,target,before,owned,owner,qualifies?aided:null,enemy,attacker,incoming,targetAtHit,attackerAtHit);}
|
||||
finally{pending.remove(event);}
|
||||
});}
|
||||
catch(RuntimeException ignored){pending.remove(event);}
|
||||
}
|
||||
private void consume(EntityDamageEvent event,Player target,double before,double ownedBefore,Grant owner,
|
||||
EquipmentSessions.Active aided,UUID enemy,Entity attacker,double incoming,
|
||||
org.bukkit.Location targetAtHit,org.bukkit.Location attackerAtHit){
|
||||
if(closed||event.isCancelled()||!Double.isFinite(event.getDamage())||event.getDamage()<=0)return;
|
||||
double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));
|
||||
if(!Double.isFinite(consumed)||consumed<=0)return;
|
||||
var list=grants.get(target.getUniqueId());if(list==null||owner.closed||!list.contains(owner)||!owner.execution.stillCurrent())return;
|
||||
var amount=BigDecimal.valueOf(consumed).stripTrailingZeros();
|
||||
try{owner.execution.report(AbilityReport.Type.PREVENTION,target,amount);}
|
||||
catch(RuntimeException ignored){/* Feedback must not affect accepted absorption. */}
|
||||
for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);
|
||||
list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());
|
||||
if(owner.observation!=null)try{owner.observation.accept(new Accepted(target,attacker,incoming,before,after,
|
||||
consumed,Math.max(0,owner.remaining),targetAtHit,attackerAtHit));}
|
||||
catch(RuntimeException ignored){/* Audit must not affect native absorption or rewards. */}
|
||||
if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,owner.execution.input().session().character(),aided,amount));
|
||||
}
|
||||
private static org.bukkit.Location auditLocation(Entity entity){
|
||||
if(entity==null)return null;
|
||||
try{var location=entity.getLocation();return location==null?null:location.clone();}
|
||||
catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private boolean sameParty(Player source,Player target){var a=parties.partyOf(source.getUniqueId()).orElse(null);var b=parties.partyOf(target.getUniqueId()).orElse(null);return a!=null&&b!=null&&a.id().equals(b.id());}
|
||||
private Grant strongest(UUID target){Grant result=null;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>0&&grant.execution.stillCurrent()&&(result==null||grant.remaining>result.remaining||grant.remaining==result.remaining&&grant.sequence>result.sequence))result=grant;return result;}
|
||||
private double owned(UUID target){double result=0;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>result)result=grant.remaining;return result;}
|
||||
private static Entity attacker(EntityDamageByEntityEvent event){Entity causing=event.getDamageSource().getCausingEntity();if(causing!=null)return causing;if(event.getDamager() instanceof Projectile p){ProjectileSource shooter=p.getShooter();if(shooter instanceof Entity e)return e;}return event.getDamager();}
|
||||
private static void addNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount(),next=current+amount;if(!Double.isFinite(current)||current<0||!Double.isFinite(next))throw new IllegalStateException("Temporary health exceeds native range");target.setAbsorptionAmount(next);}
|
||||
private static void removeNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount();if(Double.isFinite(current)&¤t>=0)target.setAbsorptionAmount(Math.max(0,current-amount));}
|
||||
@Override public void close(){if(closed)return;for(var list:List.copyOf(grants.values()))for(var grant:List.copyOf(list))grant.close();closed=true;grants.clear();}
|
||||
@Override public void close(){if(closed)return;for(var list:List.copyOf(grants.values()))for(var grant:List.copyOf(list))grant.close();closed=true;grants.clear();pending.clear();}
|
||||
}
|
||||
@@ -36,7 +36,41 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
public void open(Player player){open(player,0);}
|
||||
private void open(Player player,int requestedPage){var context=context(player);if(context==null){player.sendMessage("Quest item turn-in requires an active living character.");return;}for(var pending:fishing.pendingCatches())if(pending.character().equals(context.record().id()))recoverCatch(pending);var entries=new ArrayList<ItemStack>();var quest=fishingQuest(context);if(quest!=null){var terms=quest.fishing();entries.add(item(Material.EMERALD,"Submit "+terms.itemName(),"submit",List.of("Held: "+fishing.quantity(context.record().id(),terms.itemId())+" / "+terms.required(),"Reward: "+terms.rewardXp()+" XP, "+terms.rewardCoins()+" coins",quest.unitType()==AdventureQuestState.UnitType.PARTY?"One accepted member supplies all items":"Individual submission","Beneficiaries: "+beneficiaries(context,quest).size()+" accepted character(s)")));}for(var trophy:fishing.trophies(context.record().id())){var total=trophy.total();entries.add(item(Material.PLAYER_HEAD,"Redeem "+trophy.name(),"trophy:"+trophy.id(),List.of("Origin: "+trophy.origin(),"Reward pool: "+total.xp()+" XP, "+total.coins()+" coins","Beneficiaries: "+trophy.rewards().size()+" frozen character(s)")));}var legacy=adventures.unclaimedAll(context.record().id());if(legacy.xp().signum()>0||legacy.coins().signum()>0)entries.add(item(Material.NETHER_STAR,"Claim retained adventure rewards","legacy-claim",List.of(legacy.xp()+" XP, "+legacy.coins()+" coins","Previously earned shares do not expire")));var reward=fishing.unclaimed(context.record().id());if(reward.xp().signum()>0||reward.coins().signum()>0)entries.add(item(Material.GOLD_INGOT,"Claim earned quest rewards","claim",List.of(reward.xp()+" XP, "+reward.coins()+" coins","Earned shares do not expire")));int pages=Math.max(1,(entries.size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));UUID nonce=UUID.randomUUID();menus.put(player.getUniqueId(),nonce);var inventory=plugin.getServer().createInventory(new Holder(player.getUniqueId(),context.active().session(),context.record().id(),nonce,page),54,net.kyori.adventure.text.Component.text("Turn in quest items "+(page+1)+"/"+pages));int start=page*45;for(int index=start;index<Math.min(entries.size(),start+45);index++)inventory.setItem(index-start,entries.get(index));if(entries.isEmpty())inventory.setItem(22,item(Material.BARRIER,"No eligible fishing items or rewards",null,List.of()));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","page:"+(page-1),List.of()));if(page+1<pages)inventory.setItem(53,item(Material.ARROW,"Next","page:"+(page+1),List.of()));player.openInventory(inventory);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null||closed)return;String action=event.getCurrentItem().getItemMeta().getPersistentDataContainer().get(actionKey,PersistentDataType.STRING);if(action==null)return;var context=context(player);if(context==null||!holder.player().equals(player.getUniqueId())||!holder.session().equals(context.active().session())||!holder.character().equals(context.record().id())||!menus.remove(player.getUniqueId(),holder.nonce())){player.sendMessage("Quest item action refused: the menu, character or session changed.");return;}if(action.startsWith("page:")){try{open(player,Integer.parseInt(action.substring(5)));}catch(NumberFormatException invalid){player.sendMessage("Quest item page refused.");}return;}if(action.startsWith("trophy:")){redeemTrophy(player,context,action.substring(7));return;}if(action.equals("legacy-claim")){claimLegacy(player,context);return;}if(action.equals("claim")){claim(player,context);return;}if(action.equals("submit"))submit(player,context);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void fish(PlayerFishEvent event){if(closed)return;var player=event.getPlayer();var item=player.getInventory().getItemInMainHand();if(!nativeUse(player,item))return;var context=context(player);var quest=context==null?null:adventures.findQuest(AdventureQuestNavigation.fishingQuest(item)).orElse(null);if(context==null||quest==null||!eligible(context,quest))return;switch(event.getState()){case FISHING->{var hook=event.getHook();hooks.put(player.getUniqueId(),new HookClaim(player.getUniqueId(),context.active().session(),context.record().id(),quest.id(),hook.getUniqueId(),sampleXp()));}case CAUGHT_ENTITY->{event.setCancelled(true);hooks.remove(player.getUniqueId());event.getHook().remove();player.sendMessage("Quest Fishing Rods cannot pull entities.");}case CAUGHT_FISH->{var claim=hooks.remove(player.getUniqueId());if(claim==null||!claim.session().equals(context.active().session())||!claim.character().equals(context.record().id())||!claim.quest().equals(quest.id())||!claim.hook().equals(event.getHook().getUniqueId())||!(event.getCaught() instanceof Item caught)||!inside(player.getLocation(),quest)||!inside(event.getHook().getLocation(),quest)){event.setCancelled(true);event.setExpToDrop(0);if(event.getCaught()!=null)event.getCaught().remove();player.sendMessage("Quest catch refused: the owned hook and fishing area must remain valid.");return;}event.setCancelled(true);event.setExpToDrop(0);caught.remove();BigInteger xp=claim.sampledXp();boolean awarded=random.nextDouble(100d)<quest.fishing().chancePercent().doubleValue();UUID operation=UUID.nameUUIDFromBytes((quest.id()+":"+claim.hook()).getBytes(StandardCharsets.UTF_8));var plan=new FishingState.Catch(operation,context.record().id(),quest.id(),quest.site(),quest.fishing().itemId(),quest.fishing().itemName(),xp,awarded);fishing.prepareCatch(plan).whenComplete((status,failure)->{if(failure!=null){report(plan,"prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.APPLIED||status==FishingDirectory.PrepareStatus.EXISTING)recoverCatch(plan);});}default->{if(event.getState()!=PlayerFishEvent.State.BITE)hooks.remove(player.getUniqueId());}}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)
|
||||
public void fish(PlayerFishEvent event){
|
||||
if(closed)return;
|
||||
var player=event.getPlayer();
|
||||
var item=player.getInventory().getItemInMainHand();
|
||||
if(!nativeUse(player,item))return;
|
||||
var context=context(player);
|
||||
var quest=context==null?null:adventures.findQuest(AdventureQuestNavigation.fishingQuest(item)).orElse(null);
|
||||
if(context==null||quest==null||!eligible(context,quest))return;
|
||||
switch(event.getState()){
|
||||
case FISHING->{var hook=event.getHook();hooks.put(player.getUniqueId(),new HookClaim(player.getUniqueId(),context.active().session(),context.record().id(),quest.id(),hook.getUniqueId(),sampleXp()));}
|
||||
case LURED,BITE->{} // Approach and bite are not reels; retain the original owned hook.
|
||||
case CAUGHT_ENTITY->{event.setCancelled(true);hooks.remove(player.getUniqueId());event.getHook().remove();player.sendMessage("Quest Fishing Rods cannot pull entities.");}
|
||||
case CAUGHT_FISH->{
|
||||
var claim=hooks.get(player.getUniqueId());
|
||||
if(claim==null||!claim.session().equals(context.active().session())||!claim.character().equals(context.record().id())||!claim.quest().equals(quest.id())||!claim.hook().equals(event.getHook().getUniqueId())){
|
||||
refuseCatch(event,"Quest catch refused: the cast is no longer valid; cast again.");return;
|
||||
}
|
||||
hooks.remove(player.getUniqueId(),claim); // A late callback for another hook must not consume the current cast.
|
||||
if(!(event.getCaught() instanceof Item caught)){
|
||||
refuseCatch(event,"Quest catch refused: no native fish item was caught.");return;
|
||||
}
|
||||
if(!inside(player.getLocation(),quest)||!inside(event.getHook().getLocation(),quest)){
|
||||
refuseCatch(event,"Quest catch refused: keep yourself and the bobber inside the fishing area.");return;
|
||||
}
|
||||
event.setCancelled(true);event.setExpToDrop(0);caught.remove();event.getHook().remove();
|
||||
BigInteger xp=claim.sampledXp();boolean awarded=random.nextDouble(100d)<quest.fishing().chancePercent().doubleValue();
|
||||
UUID operation=UUID.nameUUIDFromBytes((quest.id()+":"+claim.hook()).getBytes(StandardCharsets.UTF_8));
|
||||
var plan=new FishingState.Catch(operation,context.record().id(),quest.id(),quest.site(),quest.fishing().itemId(),quest.fishing().itemName(),xp,awarded);
|
||||
fishing.prepareCatch(plan).whenComplete((status,failure)->{if(failure!=null){report(plan,"prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.APPLIED||status==FishingDirectory.PrepareStatus.EXISTING)recoverCatch(plan);});
|
||||
}
|
||||
case IN_GROUND,FAILED_ATTEMPT,REEL_IN->hooks.remove(player.getUniqueId());
|
||||
}
|
||||
}
|
||||
private void refuseCatch(PlayerFishEvent event,String reason){event.setCancelled(true);event.setExpToDrop(0);if(event.getCaught()!=null)event.getCaught().remove();event.getPlayer().sendMessage(reason);}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void quit(org.bukkit.event.player.PlayerQuitEvent event){cleanup(event.getPlayer());}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void changed(org.bukkit.event.player.PlayerItemHeldEvent event){cleanup(event.getPlayer());}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void opened(org.bukkit.event.inventory.InventoryOpenEvent event){if(event.getPlayer() instanceof Player player&&hooks.containsKey(player.getUniqueId()))cleanup(player);}
|
||||
|
||||
@@ -28,6 +28,7 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
private final CombatFeedback feedback;
|
||||
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
|
||||
private final Map<UUID, Instant> readyAt = new HashMap<>();
|
||||
private games.dmg.fantasystick.events.BasicAttackEventBridge events;
|
||||
private Pending pending;
|
||||
private boolean closed;
|
||||
|
||||
@@ -36,9 +37,18 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
final LivingEntity target;
|
||||
final Instant attackedAt;
|
||||
final boolean knockback;
|
||||
final games.dmg.fantasystick.events.BasicAttackEventBridge.Action action;
|
||||
final double requested;
|
||||
boolean accepted;
|
||||
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback) {
|
||||
boolean nativeSeen;
|
||||
boolean nativePriorCancelled;
|
||||
boolean nativeCancelled;
|
||||
Double nativeFinal;
|
||||
EntityDamageByEntityEvent acceptedEvent;
|
||||
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback,
|
||||
double requested, games.dmg.fantasystick.events.BasicAttackEventBridge.Action action) {
|
||||
this.input = input; this.target = target; this.attackedAt = attackedAt; this.knockback = knockback;
|
||||
this.requested = requested; this.action = action;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,26 +72,38 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
this.trainingDamage = Objects.requireNonNull(trainingDamage);
|
||||
}
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.BasicAttackEventBridge bridge) {
|
||||
if (closed || events != null) throw new IllegalStateException("Basic attack events already attached");
|
||||
events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) {
|
||||
if (closed || pending != null || input.kind() != ControlInput.Kind.BASIC_ATTACK
|
||||
|| input.gesture() != ControlInput.Gesture.ATTACK
|
||||
|| !(input.target() instanceof LivingEntity target) || !input.stillCurrent()) return;
|
||||
var record = sessions.currentRecord(input.session()).orElse(null);
|
||||
if (record == null) return;
|
||||
if (input.kind() != ControlInput.Kind.BASIC_ATTACK || input.gesture() != ControlInput.Gesture.ATTACK) return;
|
||||
Instant now = clock.instant();
|
||||
Instant ready = readyAt.get(input.session().character());
|
||||
if (ready != null && now.isBefore(ready)) return;
|
||||
if (closed || pending != null || !(input.target() instanceof LivingEntity) || !input.stillCurrent()) {
|
||||
refuse(input, null, now, ready, closed ? "closed" : pending != null ? "attack_in_progress"
|
||||
: !(input.target() instanceof LivingEntity) ? "ineligible_target" : "stale_input");
|
||||
return;
|
||||
}
|
||||
var target = (LivingEntity) input.target();
|
||||
var record = sessions.currentRecord(input.session()).orElse(null);
|
||||
if (record == null) { refuse(input, null, now, ready, "missing_character"); return; }
|
||||
if (ready != null && now.isBefore(ready)) { refuse(input, record, now, ready, "cadence_not_ready"); return; }
|
||||
BigInteger damage = damage(record.stats().get(Stat.STR));
|
||||
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
|
||||
var action = events == null ? null : events.observed(input, record, target, now, ready, nativeDamage);
|
||||
var attempt = new Pending(input, target, now,
|
||||
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID));
|
||||
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID), nativeDamage, action);
|
||||
pending = attempt;
|
||||
boolean completed = false;
|
||||
try {
|
||||
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
|
||||
measured.ifPresent(amount -> {
|
||||
attempt.accepted = true;
|
||||
if (feedback != null) feedback.report("Basic Attack", input.player(), AbilityReport.Type.DAMAGE, target, amount);
|
||||
});
|
||||
completed = true;
|
||||
} finally {
|
||||
if (pending == attempt) pending = null;
|
||||
if (attempt.accepted) {
|
||||
@@ -91,9 +113,24 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
|
||||
}
|
||||
}
|
||||
if (events != null) events.resolved(action, attempt.accepted ? "accepted" : !completed ? "failed"
|
||||
: attempt.nativeCancelled ? "cancelled" : "refused",
|
||||
attempt.accepted ? attempt.nativeSeen ? "native_positive_hit" : "training_measurement"
|
||||
: !completed ? "native_damage_failed" : attempt.nativePriorCancelled ? "prior_cancelled"
|
||||
: attempt.nativeCancelled ? "native_cancelled" : attempt.nativeSeen
|
||||
? "zero_or_invalid_damage" : "native_hit_unverified",
|
||||
attempt.nativePriorCancelled, attempt.nativeCancelled, attempt.nativeFinal,
|
||||
attempt.accepted ? readyAt.get(input.session().character()) : null);
|
||||
}
|
||||
}
|
||||
|
||||
private void refuse(ControlInput input, games.dmg.fantasystick.persistence.CharacterRecord record,
|
||||
Instant now, Instant ready, String reason) {
|
||||
if (events == null) return;
|
||||
var action = events.observed(input, record, input.target(), now, ready, null);
|
||||
events.resolved(action, "refused", reason, false, false, null, null);
|
||||
}
|
||||
|
||||
static BigInteger damage(BigInteger strength) {
|
||||
Objects.requireNonNull(strength);
|
||||
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
|
||||
@@ -101,13 +138,35 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
}
|
||||
|
||||
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@EventHandler(priority = EventPriority.LOWEST)
|
||||
public void observedNativeDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = pending;
|
||||
if (attempt != null && event.getDamager() == attempt.input.player() && event.getEntity() == attempt.target)
|
||||
attempt.nativePriorCancelled = event.isCancelled();
|
||||
}
|
||||
|
||||
public boolean ownsAcceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
|
||||
var attempt = pending;
|
||||
if (closed || attempt == null || !(event instanceof EntityDamageByEntityEvent by)) return false;
|
||||
if (attempt.accepted) return attempt.acceptedEvent == event;
|
||||
return by.getDamager() == attempt.input.player() && by.getEntity() == attempt.target
|
||||
&& !event.isCancelled() && Double.isFinite(event.getFinalDamage()) && event.getFinalDamage() > 0
|
||||
&& attempt.input.stillCurrent() && sessions.current(attempt.input.session());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = pending;
|
||||
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|
||||
if (closed || attempt == null || attempt.accepted || event.getDamager() != attempt.input.player()
|
||||
|| event.getEntity() != attempt.target) return;
|
||||
attempt.nativeSeen = true;
|
||||
attempt.nativeCancelled = event.isCancelled();
|
||||
if (event.isCancelled() || event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())
|
||||
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
|
||||
attempt.accepted = true;
|
||||
attempt.acceptedEvent = event;
|
||||
attempt.nativeFinal = event.getFinalDamage();
|
||||
if (events != null) events.acceptedDamage(attempt.action, event, attempt.attackedAt.plus(CADENCE));
|
||||
if (feedback != null) feedback.report("Basic Attack", attempt.input.player(), AbilityReport.Type.DAMAGE,
|
||||
event.getEntity(), java.math.BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
|
||||
}
|
||||
|
||||
@@ -18,7 +18,29 @@ import org.bukkit.entity.Entity;
|
||||
/** Shared cast and explicit effect-observation boundary; never infers effects from feedback. */
|
||||
public final class AbilityCastEventBridge {
|
||||
public record Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt) { }
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
|
||||
float attackCharge, int heldTicks, int slot, UUID executionId) {
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
|
||||
float attackCharge, int heldTicks, int slot) {
|
||||
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt,
|
||||
attackCharge, heldTicks, slot, UUID.randomUUID());
|
||||
}
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
|
||||
float attackCharge, int heldTicks) {
|
||||
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt,
|
||||
attackCharge, heldTicks, -1);
|
||||
}
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt, float attackCharge) {
|
||||
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt, attackCharge, 0);
|
||||
}
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt) {
|
||||
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt, 1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
private final Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
@@ -39,7 +61,7 @@ public final class AbilityCastEventBridge {
|
||||
definition.id(), target == null ? null : target.getUniqueId(),
|
||||
target == null ? null : target instanceof org.bukkit.entity.Player ? "PLAYER" : target.getType().name(),
|
||||
targetAtRequest == null ? Map.of() : locationData(targetAtRequest),
|
||||
clock.instant());
|
||||
clock.instant(), input.attackStrength(), input.heldTicks(), input.slot());
|
||||
emit(cast, "action.observed", "observed", Map.of("action_kind", "ability_cast"));
|
||||
return cast;
|
||||
} catch (RuntimeException ignored) {
|
||||
@@ -48,6 +70,11 @@ public final class AbilityCastEventBridge {
|
||||
}
|
||||
|
||||
public void cast(Cast cast, CharacterRecord saved, Instant deadline, AbilityDefinition definition) {
|
||||
cast(cast, saved, deadline, definition, Map.of());
|
||||
}
|
||||
|
||||
public void cast(Cast cast, CharacterRecord saved, Instant deadline, AbilityDefinition definition,
|
||||
Map<String, Object> nativeEvidence) {
|
||||
if (cast == null) return;
|
||||
try {
|
||||
var settings = new LinkedHashMap<String, Object>();
|
||||
@@ -55,10 +82,23 @@ public final class AbilityCastEventBridge {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("cooldown_until", deadline);
|
||||
data.put("after_revision", saved.revision().toString());
|
||||
if (cast.slot() >= 0) data.put("slot", cast.slot());
|
||||
data.put("stage", "cooldown_committed");
|
||||
data.put("activation", definition.activation().name().toLowerCase(java.util.Locale.ROOT));
|
||||
data.put("captured_settings", settings);
|
||||
data.put("settings_scope", "definition_at_request");
|
||||
if (cast.abilityId().equals("great_sword")) data.put("captured_charge", Float.toString(cast.attackCharge()));
|
||||
if (cast.abilityId().equals("quickstep")) {
|
||||
data.put("intended_distance", definition.parameters().get("distance").toPlainString());
|
||||
for (String key : java.util.List.of("from_location", "intended_direction", "accepted_velocity",
|
||||
"native_velocity_status"))
|
||||
if (nativeEvidence.containsKey(key)) data.put(key, nativeEvidence.get(key));
|
||||
}
|
||||
if (cast.abilityId().equals("longbow")) {
|
||||
data.put("draw_ticks", cast.heldTicks());
|
||||
data.put("draw_power", games.dmg.fantasystick.ability.Longbow.power(cast.heldTicks(),
|
||||
definition.parameters().get("full-draw-ticks").intValueExact()).stripTrailingZeros().toPlainString());
|
||||
}
|
||||
for (Stat stat : definition.requirements().keySet())
|
||||
data.put("captured_" + stat.name().toLowerCase(java.util.Locale.ROOT), saved.stats().get(stat).toString());
|
||||
emit(cast, "ability.cast", "accepted", data);
|
||||
@@ -181,6 +221,10 @@ public final class AbilityCastEventBridge {
|
||||
data.put("target_location", locationData(ally.getLocation()));
|
||||
data.put("attacker_entity_id", attacker.getUniqueId().toString());
|
||||
data.put("attacker_entity_type", entityType(attacker));
|
||||
if (cast.abilityId().equals("stone_skin")) {
|
||||
var attackLocation = attacker.getLocation();
|
||||
if (attackLocation != null) data.put("attacker_location", locationData(attackLocation));
|
||||
}
|
||||
data.put("incoming_amount", decimal(incoming));
|
||||
data.put("actual_amount", decimal(actual));
|
||||
data.put("remaining_amount", decimal(remaining));
|
||||
@@ -188,6 +232,63 @@ public final class AbilityCastEventBridge {
|
||||
} catch (RuntimeException ignored) { /* Prevention authority belongs to the native damage event. */ }
|
||||
}
|
||||
|
||||
/** Hex owns outgoing prevention even when the protected recipient is not a player. */
|
||||
public void outgoingPrevented(Cast cast, UUID effectApplication, Entity debuffedAttacker,
|
||||
Entity protectedTarget, double incoming, double remaining, double actual,
|
||||
Map<String, Object> recipientEvidence) {
|
||||
if (cast == null || !Double.isFinite(actual) || actual <= 0) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>(recipientEvidence);
|
||||
UUID application = UUID.randomUUID();
|
||||
data.put("application_id", application.toString());
|
||||
data.put("effect_application_id", effectApplication.toString());
|
||||
data.put("causation_id", cast.actionId().toString());
|
||||
data.put("debuffed_attacker_id", debuffedAttacker.getUniqueId().toString());
|
||||
data.put("debuffed_attacker_type", entityType(debuffedAttacker));
|
||||
data.put("attacker_location", locationData(debuffedAttacker.getLocation()));
|
||||
data.put("protected_target_id", protectedTarget.getUniqueId().toString());
|
||||
data.put("protected_target_type", entityType(protectedTarget));
|
||||
data.put("protected_location", locationData(protectedTarget.getLocation()));
|
||||
data.put("incoming_amount", incoming);
|
||||
data.put("remaining_amount", remaining);
|
||||
data.put("actual_amount", actual);
|
||||
emit(cast, "combat.prevented", "accepted", data, application);
|
||||
} catch (RuntimeException ignored) { /* Logging cannot alter accepted native damage. */ }
|
||||
}
|
||||
|
||||
/** Inspire reports actual accepted native absorption, not a displayed health grant. */
|
||||
public void absorbed(Cast cast, UUID effectApplication, org.bukkit.entity.Player protectedTarget,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active aided, Entity attacker,
|
||||
double incoming, double before, double after, double consumed, double remaining,
|
||||
Location targetAtHit, Location attackerAtHit) {
|
||||
if (cast == null || effectApplication == null || !Double.isFinite(consumed) || consumed <= 0) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
UUID application = UUID.randomUUID();
|
||||
data.put("application_id", application.toString());
|
||||
data.put("effect_application_id", effectApplication.toString());
|
||||
data.put("causation_id", cast.actionId().toString());
|
||||
data.put("protected_target_id", protectedTarget.getUniqueId().toString());
|
||||
data.put("protected_target_type", "PLAYER");
|
||||
if (aided != null) {
|
||||
data.put("protected_character_id", aided.character().toString());
|
||||
data.put("protected_session_id", aided.session().toString());
|
||||
}
|
||||
if (targetAtHit != null) data.put("protected_location", locationData(targetAtHit));
|
||||
if (attacker != null) {
|
||||
data.put("attacker_entity_id", attacker.getUniqueId().toString());
|
||||
data.put("attacker_entity_type", entityType(attacker));
|
||||
if (attackerAtHit != null) data.put("attacker_location", locationData(attackerAtHit));
|
||||
} else data.put("attacker_status", "not_applicable");
|
||||
data.put("incoming_amount", decimal(incoming));
|
||||
data.put("absorption_before", decimal(before));
|
||||
data.put("absorption_after", decimal(after));
|
||||
data.put("actual_amount", decimal(consumed));
|
||||
data.put("remaining_amount", decimal(remaining));
|
||||
emit(cast, "combat.prevented", "accepted", data, application);
|
||||
} catch (RuntimeException ignored) { /* Native absorption and party rewards are independent of logging. */ }
|
||||
}
|
||||
|
||||
public void boosted(Cast cast, games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
|
||||
org.bukkit.entity.Player attacker, Entity target, double base, double bonus,
|
||||
double finalDamage, java.math.BigDecimal percent) {
|
||||
@@ -223,9 +324,19 @@ public final class AbilityCastEventBridge {
|
||||
}
|
||||
|
||||
public void effectEnded(Cast cast, UUID application, String effectId, String reason) {
|
||||
effectEnded(cast, application, effectId, reason, Map.of());
|
||||
}
|
||||
|
||||
public void effectEnded(Cast cast, UUID application, String effectId, String reason,
|
||||
Map<String, Object> evidence) {
|
||||
if (cast == null) return;
|
||||
emit(cast, "effect.ended", "ended", Map.of("application_id", application.toString(),
|
||||
"effect_id", effectId, "reason", reason), application);
|
||||
try {
|
||||
var details = new LinkedHashMap<String, Object>(evidence);
|
||||
details.put("application_id", application.toString());
|
||||
details.put("effect_id", effectId);
|
||||
details.put("reason", reason);
|
||||
emit(cast, "effect.ended", "ended", details, application);
|
||||
} catch (RuntimeException ignored) { /* Native cleanup is not conditional on audit evidence. */ }
|
||||
}
|
||||
|
||||
private void emit(Cast cast, String type, String outcome, Map<String, Object> extra) {
|
||||
@@ -238,6 +349,8 @@ public final class AbilityCastEventBridge {
|
||||
data.put("action_kind", "ability_cast");
|
||||
data.put("ability_id", cast.abilityId());
|
||||
data.put("outcome", outcome);
|
||||
if (cast.abilityId().equals("frost_shard") || cast.abilityId().equals("longbow"))
|
||||
data.put("execution_id", cast.executionId().toString());
|
||||
if (cast.targetId() != null) {
|
||||
data.put("target_entity_id", cast.targetId().toString());
|
||||
data.put("target_type", cast.targetType());
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.AdminReviveSettings;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
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.command.BlockCommandSender;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Real administrator Tail restoration and acknowledged selection-timeout configuration. */
|
||||
public final class AdminRevivalEventBridge implements AutoCloseable {
|
||||
public record Actor(String kind, EventInput.Context context, UUID id) { }
|
||||
public record Selection(Actor actor, UUID targetId, String targetName,
|
||||
EventInput.Location targetLocation,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active targetSession) { }
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock;
|
||||
private final games.dmg.fantasystick.session.EquipmentSessions sessions;
|
||||
|
||||
public AdminRevivalEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, games.dmg.fantasystick.session.EquipmentSessions sessions,
|
||||
Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
outbox = new FeatureEventOutbox(data, "admin-revival-event-intents",
|
||||
java.util.Set.of("character.revived", "config.changed"), sink, failures);
|
||||
}
|
||||
|
||||
public Actor actor(CommandSender sender) {
|
||||
try {
|
||||
var player = sender instanceof Player value ? value : null;
|
||||
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
|
||||
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
|
||||
player.getName(), record == null ? null : record.id(),
|
||||
record == null ? null : record.name(), record == null ? null : active.session(), safeLocation(player)),
|
||||
player == null ? null : player.getUniqueId());
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public Selection selection(Player admin, Player target) {
|
||||
try { return new Selection(actor(admin), target.getUniqueId(), target.getName(),
|
||||
safeLocation(target), sessions.active(target.getUniqueId()).orElse(null)); }
|
||||
catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public void revived(Selection selection, RevivalStore.Reservation reservation,
|
||||
CharacterRecord dead, CharacterRecord restored, Player target) {
|
||||
if (selection == null || selection.actor() == null) return;
|
||||
try {
|
||||
var data = revivalData(reservation, restored, target);
|
||||
data.put("revision_before", dead.revision().toString());
|
||||
data.put("death_time", dead.diedAt().toString());
|
||||
data.put("death_age_seconds", Long.toString(Math.max(0, Duration.between(dead.diedAt(), clock.instant()).getSeconds())));
|
||||
data.put("recovery_reconciliation", false);
|
||||
location(data, "target_location", selection.targetLocation());
|
||||
targetSession(data, selection.targetSession());
|
||||
var actor = selection.actor();
|
||||
data.put("admin_player_id", selection.actor().id().toString());
|
||||
data.put("admin_player_name", actor.context().playerName());
|
||||
if (actor.context().location() == null) data.put("actor_location_status", "unavailable");
|
||||
durable("character.revived", reservation.id(), actor.context(), data);
|
||||
} catch (RuntimeException ignored) { /* Audit failure cannot consume or restore a Tail. */ }
|
||||
}
|
||||
|
||||
/** Reconciliation has the reservation's exact admin UUID, but never invents its old location or name. */
|
||||
public void recovered(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) {
|
||||
if (!reservation.administrative()) return;
|
||||
try {
|
||||
var data = revivalData(reservation, restored, target);
|
||||
data.put("actor_kind", "system");
|
||||
data.put("admin_player_id", reservation.casterCharacter().toString());
|
||||
data.put("admin_name_status", "unavailable");
|
||||
data.put("actor_location_status", "unavailable");
|
||||
data.put("revision_before", restored.revision().subtract(BigInteger.ONE).toString());
|
||||
data.put("death_time", restored.diedAt().toString());
|
||||
data.put("death_age_seconds", Long.toString(Math.max(0,
|
||||
Duration.between(restored.diedAt(), reservation.createdAt()).getSeconds())));
|
||||
data.put("recovery_reconciliation", true);
|
||||
location(data, "target_location", safeLocation(target));
|
||||
targetSession(data, sessions.active(target.getUniqueId()).orElse(null));
|
||||
durable("character.revived", reservation.id(), null, data);
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private Map<String, Object> revivalData(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("revival_kind", "admin_tail");
|
||||
data.put("revival_operation_id", reservation.id().toString());
|
||||
data.put("target_player_id", reservation.targetPlayer().toString());
|
||||
data.put("target_player_name", target.getName());
|
||||
data.put("character_id", restored.id().toString());
|
||||
data.put("character_name", restored.name());
|
||||
data.put("death_id", reservation.death().toString());
|
||||
data.put("revision_after", restored.revision().toString());
|
||||
data.put("tail_slot", reservation.sourceSlot());
|
||||
data.put("tail_custody_id", reservation.death().toString());
|
||||
data.put("sheet_slot", reservation.sourceAmount() == 1 ? reservation.sourceSlot() : reservation.destinationSlot());
|
||||
data.put("source_quantity_before", Integer.toString(reservation.sourceAmount()));
|
||||
data.put("source_quantity_after", Integer.toString(reservation.sourceAmount() - 1));
|
||||
data.put("restored_health", restored.health().toPlainString());
|
||||
data.put("maximum_health", restored.maximumHealth().toPlainString());
|
||||
data.put("cooldowns_retained", true);
|
||||
data.put("native_delivery", "acknowledged");
|
||||
data.put("outcome", "acknowledged");
|
||||
return data;
|
||||
}
|
||||
|
||||
public void read(Actor actor, String effective) {
|
||||
if (actor == null) return;
|
||||
var data = config(actor);
|
||||
data.put("effective", effective);
|
||||
data.put("outcome", "acknowledged_read");
|
||||
emit(UUID.randomUUID(), "config.read", actor.context(), data);
|
||||
}
|
||||
|
||||
public void changed(Actor actor, String before, AdminReviveSettings.Update saved) {
|
||||
if (actor == null) return;
|
||||
var data = config(actor);
|
||||
data.put("before", before);
|
||||
data.put("after", saved.value());
|
||||
data.put("effective", saved.value());
|
||||
data.put("outcome", "acknowledged");
|
||||
durable("config.changed", UUID.randomUUID(), actor.context(), data);
|
||||
}
|
||||
|
||||
private static Map<String, Object> config(Actor actor) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", actor.kind());
|
||||
data.put("key", AdminReviveSettings.KEY);
|
||||
data.put("applies_to", "later_selections");
|
||||
if (actor.context() == null) data.put("location_status", "not_applicable");
|
||||
else if (actor.context().location() == null) data.put("location_status", "unavailable");
|
||||
return data;
|
||||
}
|
||||
|
||||
private static void targetSession(Map<String, Object> data,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active) {
|
||||
if (active == null) { data.put("target_session_status", "no_active_session"); return; }
|
||||
data.put("target_session_id", active.session().toString());
|
||||
data.put("target_active_character_id", active.character().toString());
|
||||
data.put("target_session_status", "captured");
|
||||
}
|
||||
|
||||
private void location(Map<String, Object> data, String key, EventInput.Location value) {
|
||||
if (value == null) { data.put(key + "_status", "unavailable"); return; }
|
||||
data.put(key, 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(), "quality", value.quality()));
|
||||
}
|
||||
|
||||
private EventInput.Location safeLocation(Player player) {
|
||||
try { return location(player.getLocation()); }
|
||||
catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
try {
|
||||
if (value == null || value.getWorld() == null) return null;
|
||||
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(),
|
||||
value.getX(), value.getY(), value.getZ(), clock.instant(), "exact");
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private void durable(String type, UUID action, EventInput.Context context, Map<String, Object> data) {
|
||||
try { outbox.accepted(new EventInput(id(action, type), action, clock.instant(), type, context, data)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
private void emit(UUID action, String type, EventInput.Context context, Map<String, Object> data) {
|
||||
try { sink.apply(new EventInput(id(action, type), action, clock.instant(), type, context, data)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
private static UUID id(UUID action, String kind) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:admin-revival:" + action + ":" + kind)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.BlockCommandSender;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Captures actual identity-creating and identity-changing native actions, never previews. */
|
||||
@@ -37,7 +39,7 @@ public final class CharacterIdentityEventBridge implements AutoCloseable {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.activeSession = Objects.requireNonNull(activeSession);
|
||||
this.outbox = new FeatureEventOutbox(Objects.requireNonNull(dataFolder),
|
||||
"character-identity-event-intents", java.util.Set.of("character.created", "character.renamed"),
|
||||
"character-identity-event-intents", java.util.Set.of("character.created", "character.renamed", "character.issued"),
|
||||
sink, Objects.requireNonNull(failures));
|
||||
}
|
||||
|
||||
@@ -74,6 +76,57 @@ public final class CharacterIdentityEventBridge implements AutoCloseable {
|
||||
} catch (RuntimeException ignored) { /* Observation never authorizes a character session. */ }
|
||||
}
|
||||
|
||||
public record Issue(UUID operation, String actorKind, EventInput.Context actor,
|
||||
UUID targetPlayer, String targetName) { }
|
||||
|
||||
/** Capture the administrator before asynchronous saving; no second admin.action is produced. */
|
||||
public Issue issue(CommandSender sender, Player target) {
|
||||
try {
|
||||
var player = sender instanceof Player actor ? actor : null;
|
||||
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
|
||||
EventInput.Context context = player == null ? null : new EventInput.Context(player.getUniqueId(),
|
||||
player.getName(), null, null, null, safeLocation(player));
|
||||
return new Issue(UUID.randomUUID(), kind, context, target.getUniqueId(), target.getName());
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
/** The saved character and exact native inventory slot were acknowledged before this call. */
|
||||
public void issued(Issue issue, Player target, CharacterRecord record, int slot) {
|
||||
if (issue == null) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", issue.actorKind());
|
||||
data.put("issue_operation_id", issue.operation().toString());
|
||||
data.put("target_player_id", issue.targetPlayer().toString());
|
||||
data.put("target_player_name", issue.targetName());
|
||||
data.put("character_id", record.id().toString());
|
||||
data.put("character_name", record.name());
|
||||
data.put("revision", record.revision().toString());
|
||||
data.put("generation", record.sheetGeneration().toString());
|
||||
data.put("starting_level", Integer.toString(record.level()));
|
||||
data.put("starting_unspent_points", record.unspentPoints().toString());
|
||||
data.put("starting_health", record.health().toPlainString());
|
||||
data.put("starting_coins", record.coins().toString());
|
||||
var stats = new LinkedHashMap<String, Object>();
|
||||
record.stats().forEach((stat, value) -> stats.put(stat.name().toLowerCase(java.util.Locale.ROOT), value.toString()));
|
||||
data.put("starting_stats", stats);
|
||||
data.put("delivered_slot", slot);
|
||||
data.put("delivery_outcome", "native_inventory_acknowledged");
|
||||
data.put("outcome", "acknowledged_delivered");
|
||||
if (issue.actor() == null) data.put("actor_location_status", "not_applicable");
|
||||
else if (issue.actor().location() == null) data.put("actor_location_status", "unavailable");
|
||||
var targetLocation = safeLocation(target);
|
||||
if (targetLocation != null) data.put("target_location", locationMap(targetLocation, "exact"));
|
||||
else data.put("target_location_status", "unavailable");
|
||||
durable("character.issued", issue.operation(), clock.instant(), issue.actor(), data, "issued");
|
||||
} catch (RuntimeException ignored) { /* Audit cannot change acknowledged native issuance. */ }
|
||||
}
|
||||
|
||||
private EventInput.Location safeLocation(Player player) {
|
||||
try { return location(player.getLocation()); }
|
||||
catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public record Craft(UUID action, Instant time, EventInput.Context actor, String route, int quantity,
|
||||
UUID holderSession) { }
|
||||
|
||||
|
||||
@@ -100,15 +100,14 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
fields.setProperty("quality", location.quality());
|
||||
}
|
||||
event.data().forEach((key, value) -> {
|
||||
if (value instanceof String text) fields.setProperty("data." + key, "s" + text);
|
||||
else if (value instanceof Boolean bool) fields.setProperty("data." + key, "b" + bool);
|
||||
else if (value instanceof Integer number) fields.setProperty("data." + key, "i" + number);
|
||||
else if (value instanceof Double number && Double.isFinite(number))
|
||||
fields.setProperty("data." + key, "d" + number);
|
||||
else if (value instanceof java.util.List<?> list && list.stream().allMatch(item -> item instanceof String text
|
||||
&& (text.equals("hotbar") || text.equals("armor"))))
|
||||
fields.setProperty("data." + key, "l" + String.join(",", list.stream().map(String.class::cast).toList()));
|
||||
else throw new IllegalArgumentException("Unsupported feature intent value");
|
||||
if (value instanceof java.util.Map<?, ?> nested) {
|
||||
if (key.contains(".")) throw new IllegalArgumentException("Unsafe nested feature intent key");
|
||||
nested.forEach((name, part) -> {
|
||||
if (!(name instanceof String child) || child.contains("."))
|
||||
throw new IllegalArgumentException("Unsafe nested feature intent key");
|
||||
encode(fields, "data." + key + "." + child, part);
|
||||
});
|
||||
} else encode(fields, "data." + key, value);
|
||||
});
|
||||
var output = new ByteArrayOutputStream();
|
||||
fields.store(output, "acknowledged character identity event");
|
||||
@@ -127,6 +126,17 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
return event;
|
||||
}
|
||||
|
||||
private static void encode(Properties fields, String key, Object value) {
|
||||
if (value instanceof String text) fields.setProperty(key, "s" + text);
|
||||
else if (value instanceof Boolean bool) fields.setProperty(key, "b" + bool);
|
||||
else if (value instanceof Integer number) fields.setProperty(key, "i" + number);
|
||||
else if (value instanceof Double number && Double.isFinite(number)) fields.setProperty(key, "d" + number);
|
||||
else if (value instanceof java.util.List<?> list && list.stream().allMatch(item -> item instanceof String text
|
||||
&& (text.equals("hotbar") || text.equals("armor"))))
|
||||
fields.setProperty(key, "l" + String.join(",", list.stream().map(String.class::cast).toList()));
|
||||
else throw new IllegalArgumentException("Unsupported feature intent value");
|
||||
}
|
||||
|
||||
private EventInput read(Path path) throws IOException {
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event intent");
|
||||
var fields = new Properties();
|
||||
@@ -155,10 +165,11 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
fields.containsKey("session") ? UUID.fromString(fields.getProperty("session")) : null, location)
|
||||
: null;
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
var nested = new LinkedHashMap<String, java.util.Map<String, Object>>();
|
||||
for (var key : fields.stringPropertyNames()) if (key.startsWith("data.")) {
|
||||
var value = fields.getProperty(key);
|
||||
if (value.isEmpty()) throw new IOException("Invalid character event value");
|
||||
data.put(key.substring(5), switch (value.charAt(0)) {
|
||||
var decoded = switch (value.charAt(0)) {
|
||||
case 's' -> value.substring(1);
|
||||
case 'b' -> {
|
||||
if (!value.equals("btrue") && !value.equals("bfalse")) throw new IOException("Invalid boolean");
|
||||
@@ -178,8 +189,20 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
yield list;
|
||||
}
|
||||
default -> throw new IOException("Invalid feature event value type");
|
||||
});
|
||||
};
|
||||
var name = key.substring(5);
|
||||
var separator = name.indexOf('.');
|
||||
if (separator < 0) data.put(name, decoded);
|
||||
else {
|
||||
if (separator == 0 || separator == name.length() - 1 || name.indexOf('.', separator + 1) >= 0)
|
||||
throw new IOException("Invalid nested feature intent key");
|
||||
nested.computeIfAbsent(name.substring(0, separator), ignored -> new LinkedHashMap<>())
|
||||
.put(name.substring(separator + 1), decoded);
|
||||
}
|
||||
}
|
||||
nested.forEach((key, value) -> {
|
||||
if (data.putIfAbsent(key, value) != null) throw new IllegalArgumentException("Conflicting feature intent key");
|
||||
});
|
||||
return new EventInput(id, action, Instant.parse(fields.getProperty("time")), type, context, data);
|
||||
} catch (RuntimeException failure) { throw new IOException("Invalid character event intent", failure); }
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,9 @@ public final class RecoveryEventBridge {
|
||||
public void refused(Action action,String reason){emit("action.resolved",action,"refused",reason,null,null,null,null);}
|
||||
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null,null,null);}
|
||||
public void healed(Action action,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){emit("action.resolved",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);emit("combat.healed",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);}
|
||||
private void emit(String type,Action action,String outcome,String reason,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){var data=new LinkedHashMap<String,Object>();data.put("action_kind","out_of_combat_recovery");data.put("recovery_kind","out_of_combat");data.put("cause","recovery_timer");data.put("expected_revision",action.expectedRevision().toString());data.put("health_before",action.healthBefore().toPlainString());data.put("proposed_total",action.proposed().toPlainString());data.put("configured_base",action.configuredBase().toString());data.put("configured_quicker_regeneration_i",action.configuredQuickerRegeneration().toString());data.put("outcome",outcome);if(healthAfter!=null)data.put("health_after",healthAfter.toPlainString());if(acceptedTotal!=null){data.put("accepted_total",acceptedTotal.toPlainString());data.put("base_contribution",baseContribution.toPlainString());if(passiveContribution.signum()>0)data.put("passive_contributions",Map.of("quicker_regeneration_i",passiveContribution.toPlainString()));data.put("native_publication","acknowledged");}if(reason!=null)data.put(type.equals("combat.healed")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
|
||||
private void emit(String type,Action action,String outcome,String reason,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){var data=new LinkedHashMap<String,Object>();data.put("action_kind","out_of_combat_recovery");data.put("recovery_kind","out_of_combat");data.put("cause","recovery_timer");data.put("expected_revision",action.expectedRevision().toString());data.put("health_before",action.healthBefore().toPlainString());data.put("proposed_total",action.proposed().toPlainString());data.put("configured_base",action.configuredBase().toString());data.put("configured_quicker_regeneration_i",action.configuredQuickerRegeneration().toString());data.put("outcome",outcome);if(action.context().location()==null)data.put("location_status","unavailable");if(healthAfter!=null)data.put("health_after",healthAfter.toPlainString());if(acceptedTotal!=null){data.put("accepted_total",acceptedTotal.toPlainString());data.put("base_contribution",baseContribution.toPlainString());if(passiveContribution.signum()>0)data.put("passive_contributions",Map.of("quicker_regeneration_i",passiveContribution.toPlainString()));data.put("native_publication","acknowledged");}if(reason!=null)data.put(type.equals("combat.healed")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
|
||||
}
|
||||
private EventInput.Context context(Player player,EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
|
||||
private EventInput.Context context(Player player,EquipmentSessions.Active active,String name){EventInput.Location captured;try{captured=location(player.getLocation());}catch(RuntimeException unavailable){captured=null;}return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),captured);}
|
||||
private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");}
|
||||
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:recovery:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.SwitchDelaySettings;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
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.command.BlockCommandSender;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Observes the registered administrator's acknowledged switch policy; never changes deadlines. */
|
||||
public final class SwitchConfigEventBridge implements AutoCloseable {
|
||||
public record Actor(String kind, EventInput.Context context) { }
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final EquipmentSessions sessions;
|
||||
private final FeatureEventOutbox outbox;
|
||||
|
||||
public SwitchConfigEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, EquipmentSessions sessions,
|
||||
Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
outbox = new FeatureEventOutbox(data, "switch-config-event-intents", java.util.Set.of("config.changed"),
|
||||
sink, failures);
|
||||
}
|
||||
|
||||
public Actor actor(CommandSender sender) {
|
||||
try {
|
||||
var player = sender instanceof Player value ? value : null;
|
||||
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
|
||||
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
|
||||
player.getName(), record == null ? null : record.id(),
|
||||
record == null ? null : record.name(), record == null ? null : active.session(),
|
||||
safeLocation(player)));
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public void read(Actor actor, String effective) {
|
||||
if (actor == null) return;
|
||||
var data = common(actor);
|
||||
data.put("effective", effective);
|
||||
data.put("outcome", "acknowledged_read");
|
||||
var action = UUID.randomUUID();
|
||||
try { sink.apply(new EventInput(id(action, "config.read"), action, clock.instant(),
|
||||
"config.read", actor.context(), data)); }
|
||||
catch (RuntimeException ignored) { /* Observation cannot change the policy. */ }
|
||||
}
|
||||
|
||||
public void changed(Actor actor, UUID operation, String before, String effective,
|
||||
SwitchDelaySettings.Update saved) {
|
||||
if (actor == null) return;
|
||||
try {
|
||||
var data = common(actor);
|
||||
data.put("operation_id", operation.toString());
|
||||
data.put("store_sequence", Long.toString(saved.sequence()));
|
||||
data.put("before", before);
|
||||
data.put("after", saved.value());
|
||||
data.put("effective", effective);
|
||||
data.put("zero_disabled", saved.delay().isZero());
|
||||
data.put("outcome", "acknowledged");
|
||||
outbox.accepted(new EventInput(id(operation, "config.changed"), operation, clock.instant(),
|
||||
"config.changed", actor.context(), data));
|
||||
} catch (RuntimeException ignored) { /* A failed audit cannot roll back an effective setting. */ }
|
||||
}
|
||||
|
||||
private static Map<String, Object> common(Actor actor) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("key", SwitchDelaySettings.KEY);
|
||||
data.put("default", SwitchDelaySettings.DEFAULT);
|
||||
data.put("application_scope", "future_exit");
|
||||
data.put("existing_deadlines_unchanged", true);
|
||||
data.put("actor_kind", actor.kind());
|
||||
if (actor.context() == null) data.put("location_status", "not_applicable");
|
||||
else if (actor.context().location() == null) data.put("location_status", "unavailable");
|
||||
return data;
|
||||
}
|
||||
|
||||
private EventInput.Location safeLocation(Player player) {
|
||||
try {
|
||||
Location location = player.getLocation();
|
||||
if (location == null || location.getWorld() == null) return null;
|
||||
var world = location.getWorld();
|
||||
return new EventInput.Location(world.getUID(), world.getName(), location.getX(),
|
||||
location.getY(), location.getZ(), clock.instant(), "exact");
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private static UUID id(UUID action, String kind) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:switch-config:" + action + ":" + kind)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -76,11 +76,12 @@ public final class FrostShardSettings implements AutoCloseable {
|
||||
public FrostShard.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.GreatSword;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Serialized Great Sword configuration; the caller publishes acknowledged changes on the server thread. */
|
||||
public final class GreatSwordSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_DAMAGE = "great-sword-base-damage";
|
||||
public static final String RECOVERY = "great-sword-recovery-seconds";
|
||||
private static final List<String> KEYS = List.of(BASE_DAMAGE, RECOVERY);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "7", RECOVERY, "0.75");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, GreatSword.Settings settings, Map<String, String> values, long sequence) {
|
||||
public Update { values = Map.copyOf(values); }
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-great-sword-configuration"));
|
||||
private volatile Map<String, String> effectiveValues;
|
||||
private Map<String, String> persistedValues;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public GreatSwordSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
GreatSwordSettings(Path directory, Map<String, String> initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
var copy = new LinkedHashMap<String, String>();
|
||||
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
|
||||
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
|
||||
}
|
||||
|
||||
public static List<String> keys() { return KEYS; }
|
||||
public static String defaultValue(String key) { return DEFAULTS.get(key); }
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public GreatSword.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence(); return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
Objects.requireNonNull(key); Objects.requireNonNull(value);
|
||||
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
|
||||
String accepted = canonical(key, value);
|
||||
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted); parse(preview);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
|
||||
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
|
||||
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Great Sword configuration save refused or failed; effective settings unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String key, String raw) {
|
||||
try {
|
||||
if (key.equals(BASE_DAMAGE)) return new java.math.BigInteger(raw).toString();
|
||||
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static GreatSword.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var seconds = new java.math.BigDecimal(values.get(RECOVERY));
|
||||
Duration recovery = Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
|
||||
return new GreatSword.Settings(new java.math.BigInteger(values.get(BASE_DAMAGE)), recovery);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Great Sword settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private void save(String key, String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS))
|
||||
throw new IOException("Unsafe lock");
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
|
||||
document.put(savedKey, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
|
||||
}
|
||||
@@ -72,11 +72,12 @@ public final class HexSettings implements AutoCloseable {
|
||||
public Hex.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -72,11 +72,12 @@ public final class InspireSettings implements AutoCloseable {
|
||||
public Inspire.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -77,11 +77,12 @@ public final class LongbowSettings implements AutoCloseable {
|
||||
public Longbow.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -71,11 +71,12 @@ public final class QuickstepSettings implements AutoCloseable {
|
||||
public Quickstep.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -70,11 +70,12 @@ public final class SecondWindSettings implements AutoCloseable {
|
||||
public SecondWind.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -74,11 +74,12 @@ public final class ShieldBashSettings implements AutoCloseable {
|
||||
public ShieldBash.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -71,11 +71,12 @@ public final class StoneSkinSettings implements AutoCloseable {
|
||||
public StoneSkin.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Persists one fresh character before handing its authentic sheet to a still-valid native inventory slot. */
|
||||
@@ -22,6 +23,11 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
private final Consumer<Failure> failures;
|
||||
private final ConcurrentHashMap<UUID, Attempt> pending = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
|
||||
|
||||
public void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
|
||||
identityEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
public AdminCharacterIssuer(CharacterStorage storage, Supplier<CharacterRecord> allocate,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures) {
|
||||
@@ -32,12 +38,13 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
}
|
||||
|
||||
/** Must be entered on the server thread. Completion is delivered on that thread. */
|
||||
public void issue(Player player, Consumer<Outcome> response) {
|
||||
Objects.requireNonNull(player); Objects.requireNonNull(response);
|
||||
public void issue(CommandSender sender, Player player, Consumer<Outcome> response) {
|
||||
Objects.requireNonNull(sender); Objects.requireNonNull(player); Objects.requireNonNull(response);
|
||||
if (closed || !player.isOnline()) { response.accept(Outcome.STALE); return; }
|
||||
int slot = player.getInventory().firstEmpty();
|
||||
if (slot < 0) { response.accept(Outcome.FULL); return; }
|
||||
var attempt = new Attempt(player, slot, response);
|
||||
var audit = identityEvents;
|
||||
var attempt = new Attempt(player, slot, response, audit == null ? null : audit.issue(sender, player));
|
||||
if (pending.putIfAbsent(attempt.playerId, attempt) != null) { response.accept(Outcome.BUSY); return; }
|
||||
try {
|
||||
attempt.record = allocate.get();
|
||||
@@ -74,9 +81,15 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
|| !claimed.orElseThrow().getFirst().id().equals(attempt.record.id())) {
|
||||
abandon(attempt); respond(attempt, Outcome.RETAINED); return;
|
||||
}
|
||||
var savedRecord = claimed.orElseThrow().getFirst();
|
||||
try {
|
||||
var sheet = new CharacterSheets().create(attempt.record);
|
||||
var sheets = new CharacterSheets();
|
||||
var sheet = sheets.create(savedRecord);
|
||||
attempt.player.getInventory().setItem(attempt.slot, sheet);
|
||||
if (!sheets.identify(attempt.player.getInventory().getItem(attempt.slot))
|
||||
.filter(savedRecord.id()::equals).isPresent()) {
|
||||
respond(attempt, Outcome.RETAINED); return;
|
||||
}
|
||||
attempt.delivered = true;
|
||||
} catch (RuntimeException uncertain) {
|
||||
report(attempt, "delivery", uncertain);
|
||||
@@ -85,6 +98,8 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
}
|
||||
try { attempt.player.updateInventory(); }
|
||||
catch (RuntimeException syncFailure) { report(attempt, "sync", syncFailure); }
|
||||
var events = identityEvents;
|
||||
if (events != null) events.issued(attempt.issue, attempt.player, savedRecord, attempt.slot);
|
||||
respond(attempt, Outcome.DELIVERED);
|
||||
}
|
||||
|
||||
@@ -114,8 +129,12 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
|
||||
private static final class Attempt {
|
||||
final Player player; final UUID playerId; final int slot; final Consumer<Outcome> response;
|
||||
final games.dmg.fantasystick.events.CharacterIdentityEventBridge.Issue issue;
|
||||
final java.util.concurrent.atomic.AtomicBoolean abandoned = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
CharacterRecord record; CharacterStorage.CraftPreparation preparation; volatile boolean delivered;
|
||||
Attempt(Player player, int slot, Consumer<Outcome> response) { this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; }
|
||||
Attempt(Player player, int slot, Consumer<Outcome> response,
|
||||
games.dmg.fantasystick.events.CharacterIdentityEventBridge.Issue issue) {
|
||||
this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; this.issue=issue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,12 @@ public final class AdminRevival implements Listener, AutoCloseable {
|
||||
private final Consumer<Runnable> nextTick; private final Scheduler scheduler; private final PopupFactory popups;
|
||||
private final Consumer<Throwable> failures; private final TailBooks tails=new TailBooks();private final CharacterSheets sheets=new CharacterSheets();
|
||||
private final Map<Inventory,View> views=new IdentityHashMap<>();private boolean closed;
|
||||
private games.dmg.fantasystick.events.AdminRevivalEventBridge auditEvents;
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events) {
|
||||
if (closed || auditEvents != null) throw new IllegalStateException("Admin revival events already attached");
|
||||
auditEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
private final class View implements AutoCloseable {
|
||||
final Player admin,target;final Inventory inventory;final Map<Integer,Choice> choices;final Instant deadline;final AutoCloseable expiry;boolean selecting,finished;
|
||||
@@ -58,9 +64,9 @@ public final class AdminRevival implements Listener, AutoCloseable {
|
||||
@EventHandler(priority=EventPriority.MONITOR) public void quit(org.bukkit.event.player.PlayerQuitEvent event){for(var view:List.copyOf(views.values()))if(view.admin==event.getPlayer()||view.target==event.getPlayer())view.close();}
|
||||
|
||||
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival could not recheck that Tail; no item changed.");view.close();return;}if(!view.current()||found.isEmpty()||!eligible(found.orElseThrow(),choice.candidate.identity)||!unchanged(view.target,choice.candidate)){view.admin.sendMessage("That Tail selection is stale; no item changed.");view.close();return;}reserve(view,choice.candidate,found.orElseThrow());}));}
|
||||
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation);}));}
|
||||
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);var audit=auditEvents==null?null:auditEvents.selection(view.admin,view.target);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation,audit);}));}
|
||||
private static int emptySlot(Player target,int source){for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40||slot==source)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())return slot;}return-1;}
|
||||
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt(),dead.sheetGeneration());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
|
||||
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation,games.dmg.fantasystick.events.AdminRevivalEventBridge.Selection audit){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt(),dead.sheetGeneration());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){if(auditEvents!=null)auditEvents.revived(audit,reservation,dead,restored.orElseThrow(),view.target);reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
|
||||
private boolean installFinal(Player target,CharacterRecord record){int found=-1;for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;if(sheets.identify(target.getInventory().getItem(slot)).filter(record.id()::equals).isEmpty())continue;if(found>=0)return false;found=slot;}if(found<0)return false;target.getInventory().setItem(found,sheets.create(record));target.updateInventory();return true;}
|
||||
private void schedule(Runnable action){try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
|
||||
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
|
||||
@@ -27,6 +27,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private volatile boolean closed;
|
||||
private games.dmg.fantasystick.persistence.AdventureSettings settings;
|
||||
private games.dmg.fantasystick.persistence.FireballSettings fireballSettings;
|
||||
private games.dmg.fantasystick.persistence.GreatSwordSettings greatSwordSettings;
|
||||
private games.dmg.fantasystick.ability.GreatSword greatSword;
|
||||
private games.dmg.fantasystick.events.AbilityConfigEventBridge abilityConfigEvents;
|
||||
private games.dmg.fantasystick.ability.Fireball fireball;
|
||||
private games.dmg.fantasystick.persistence.FrostShardSettings frostSettings;
|
||||
@@ -66,9 +68,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.ability.Meteor meteor;
|
||||
private AdminCharacterIssuer characterIssuer;
|
||||
private AdminRevival adminRevival;
|
||||
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
|
||||
private AdminExperienceGrant experienceGrant;
|
||||
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
|
||||
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
|
||||
private games.dmg.fantasystick.events.SwitchConfigEventBridge switchConfigEvents;
|
||||
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
|
||||
private games.dmg.fantasystick.persistence.PartySettings partySettings;
|
||||
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
|
||||
@@ -334,6 +338,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachSwitchConfigEvents(games.dmg.fantasystick.events.SwitchConfigEventBridge events){
|
||||
if(closed||switchDelaySettings==null||switchConfigEvents!=null)throw new IllegalStateException("Switch config events unavailable");
|
||||
switchConfigEvents=Objects.requireNonNull(events);
|
||||
}
|
||||
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
|
||||
public void attachPartyEconomySettings(games.dmg.fantasystick.persistence.PartyEconomySettings settings){if(closed||partyEconomySettings!=null)throw new IllegalStateException("Party economy settings already attached or command closed");partyEconomySettings=Objects.requireNonNull(settings);}
|
||||
@@ -396,11 +404,23 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
adminRevival=Objects.requireNonNull(revival);adminReviveSettings=Objects.requireNonNull(settings);
|
||||
}
|
||||
|
||||
public void attachAdminRevivalEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events) {
|
||||
if (closed || adminRevivalEvents != null || adminRevival == null)
|
||||
throw new IllegalStateException("Admin revival event bridge unavailable");
|
||||
adminRevivalEvents = Objects.requireNonNull(events);
|
||||
adminRevival.attachEvents(events);
|
||||
}
|
||||
|
||||
public void attachCharacterIssuer(AdminCharacterIssuer issuer) {
|
||||
if (closed || characterIssuer != null) throw new IllegalStateException("Character issuer already attached or command closed");
|
||||
characterIssuer = Objects.requireNonNull(issuer);
|
||||
}
|
||||
|
||||
public void attachCharacterIssueEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
|
||||
if (closed || characterIssuer == null) throw new IllegalStateException("Character issuer unavailable");
|
||||
characterIssuer.attachIdentityEvents(events);
|
||||
}
|
||||
|
||||
public void attachCharacterBooks(games.dmg.fantasystick.book.CharacterBookController books,
|
||||
games.dmg.fantasystick.persistence.CharacterStorage storage) {
|
||||
if (closed || characterBooks != null) throw new IllegalStateException("Character books already attached or command closed");
|
||||
@@ -426,6 +446,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
partyRegistrars=Objects.requireNonNull(directory); partyRegistrarController=Objects.requireNonNull(controller);
|
||||
}
|
||||
|
||||
public void attachGreatSword(games.dmg.fantasystick.persistence.GreatSwordSettings settings,
|
||||
games.dmg.fantasystick.ability.GreatSword effect) {
|
||||
if (closed || greatSwordSettings != null) throw new IllegalStateException("Great Sword already attached or command closed");
|
||||
greatSwordSettings = Objects.requireNonNull(settings); greatSword = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachFireball(games.dmg.fantasystick.persistence.FireballSettings settings,
|
||||
games.dmg.fantasystick.ability.Fireball effect) {
|
||||
if (closed || fireballSettings != null) throw new IllegalStateException("Fireball already attached or command closed");
|
||||
@@ -951,7 +977,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
sender.sendMessage("Character issue refused: target must name one online player. No character or item was created."); return;
|
||||
}
|
||||
var target = matches.getFirst();
|
||||
characterIssuer.issue(target, outcome -> {
|
||||
characterIssuer.issue(sender, target, outcome -> {
|
||||
if (closed || !sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(switch (outcome) {
|
||||
@@ -1050,12 +1076,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (fireballSettings != null && games.dmg.fantasystick.persistence.FireballSettings.keys().contains(args[1])) {
|
||||
fireballConfiguration(sender, args); return;
|
||||
}
|
||||
if (greatSwordSettings != null && games.dmg.fantasystick.persistence.GreatSwordSettings.keys().contains(args[1])) {
|
||||
greatSwordConfiguration(sender, args); return;
|
||||
}
|
||||
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
|
||||
configurationUsage(sender); return;
|
||||
}
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = frostSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + frostSettings.value(key) + " (applies to future Frost Shard casts only)");
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "frost_shard", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Frost Shard casts only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostShardSettings.Update> operation;
|
||||
@@ -1069,21 +1101,56 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
frostShard.configure(saved.settings());
|
||||
boolean applied = failure == null && frostSettings.publish(saved);
|
||||
if (applied) {
|
||||
frostShard.configure(frostSettings.settings());
|
||||
abilities.replace(frostShard.definition());
|
||||
frostSettings.publish(saved);
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "frost_shard", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Frost Shard casts; active effects and deadlines are unchanged."
|
||||
: "Frost Shard configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Frost Shard configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Frost Shard casts; active effects and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + frostSettings.value(key) + ".");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void greatSwordConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = greatSwordSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "great_sword", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Great Sword strikes only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GreatSwordSettings.Update> operation;
|
||||
try { operation = greatSwordSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Great Sword setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
boolean applied = failure == null && greatSwordSettings.publish(saved);
|
||||
if (applied) {
|
||||
greatSword.configure(greatSwordSettings.settings()); abilities.replace(greatSword.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "great_sword", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure != null
|
||||
? "Great Sword configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Great Sword strikes; existing deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + greatSwordSettings.value(key) + ".");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void fireballConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
@@ -1238,8 +1305,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void longbowConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = longbowSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + longbowSettings.value(key) + " (applies to future Longbow shots only)");
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "longbow", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Longbow shots only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.LongbowSettings.Update> operation;
|
||||
@@ -1253,16 +1323,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
longbow.configure(saved.settings());
|
||||
boolean applied = failure == null && longbowSettings.publish(saved);
|
||||
if (applied) {
|
||||
longbow.configure(longbowSettings.settings());
|
||||
abilities.replace(longbow.definition());
|
||||
longbowSettings.publish(saved);
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "longbow", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Longbow shots; active shots and deadlines are unchanged."
|
||||
: "Longbow configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Longbow configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Longbow shots; active shots and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + longbowSettings.value(key) + ".");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
@@ -1270,8 +1342,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void quickstepConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = quickstepSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + quickstepSettings.value(key) + " (applies to future Quickstep dashes only)");
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "quickstep", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Quickstep dashes only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickstepSettings.Update> operation;
|
||||
@@ -1285,21 +1360,56 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
quickstep.configure(saved.settings()); abilities.replace(quickstep.definition());
|
||||
quickstepSettings.publish(saved);
|
||||
boolean applied = failure == null && quickstepSettings.publish(saved);
|
||||
if (applied) {
|
||||
quickstep.configure(quickstepSettings.settings()); abilities.replace(quickstep.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "quickstep", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Quickstep dashes; active movement and deadlines are unchanged."
|
||||
: "Quickstep configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Quickstep configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Quickstep dashes; active movement and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + quickstepSettings.value(key) + ".");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void inspireConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+inspireSettings.value(key)+" (applies to future Inspire casts only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.InspireSettings.Update> operation;try{operation=inspireSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Inspire setting is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null){inspire.configure(saved.settings());abilities.replace(inspire.definition());inspireSettings.publish(saved);}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+key+"="+saved.value()+" for future Inspire casts; active effects and deadlines are unchanged.":"Inspire configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
private void inspireConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = inspireSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "inspire", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Inspire casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.InspireSettings.Update> operation;
|
||||
try { operation = inspireSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Inspire setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
String effectiveBefore = failure == null ? inspireSettings.value(key) : null;
|
||||
boolean applied = failure == null && inspireSettings.publish(saved);
|
||||
if (applied) {
|
||||
inspire.configure(inspireSettings.settings()); abilities.replace(inspire.definition());
|
||||
if (abilityConfigEvents != null)
|
||||
abilityConfigEvents.changed(actor, "inspire", key, effectiveBefore, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure != null
|
||||
? "Inspire configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Inspire casts; active effects and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + inspireSettings.value(key) + ".");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void mendingLightConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
@@ -1583,19 +1693,47 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
private void hexConfiguration(CommandSender sender, String[] args) {
|
||||
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+hexSettings.value(key)+" (applies to future Hex casts only)");return;}
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = hexSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "hex", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Hex casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HexSettings.Update> operation;
|
||||
try{operation=hexSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Hex setting is invalid; the effective value is unchanged.");return;}
|
||||
operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;
|
||||
try{nextTick.accept(()->{if(closed)return;if(failure==null){hex.configure(saved.settings());abilities.replace(hex.definition());hexSettings.publish(saved);}
|
||||
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
|
||||
sender.sendMessage(failure==null?"Saved "+key+"="+saved.value()+" for future Hex casts; active effects and deadlines are unchanged."
|
||||
:"Hex configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
|
||||
try { operation = hexSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Hex setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
String effectiveBefore = failure == null ? hexSettings.value(key) : null;
|
||||
boolean applied = failure == null && hexSettings.publish(saved);
|
||||
if (applied) {
|
||||
hex.configure(hexSettings.settings()); abilities.replace(hex.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "hex", key, effectiveBefore, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure != null
|
||||
? "Hex configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Hex casts; active effects and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + hexSettings.value(key) + ".");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void shieldBashConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) { sender.sendMessage(key + "=" + shieldBashSettings.value(key) + " (applies to future Shield Bash strikes only)"); return; }
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = shieldBashSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "shield_bash", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Shield Bash strikes only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ShieldBashSettings.Update> operation;
|
||||
try { operation = shieldBashSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) { sender.sendMessage("Shield Bash setting is invalid; the effective value is unchanged."); return; }
|
||||
@@ -1604,22 +1742,29 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
shieldBash.configure(saved.settings()); abilities.replace(shieldBash.definition()); shieldBashSettings.publish(saved);
|
||||
String effectiveBefore = failure == null ? shieldBashSettings.value(key) : null;
|
||||
boolean applied = failure == null && shieldBashSettings.publish(saved);
|
||||
if (applied) {
|
||||
shieldBash.configure(shieldBashSettings.settings()); abilities.replace(shieldBash.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "shield_bash", key, effectiveBefore, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Shield Bash strikes; active work and deadlines are unchanged."
|
||||
: "Shield Bash configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Shield Bash configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Shield Bash strikes; active work and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + shieldBashSettings.value(key) + ".");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void stoneSkinConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = stoneSkinSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + stoneSkinSettings.value(key) + " (applies to future Stone Skin casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "stone_skin", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Stone Skin casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.StoneSkinSettings.Update> operation;
|
||||
try { operation = stoneSkinSettings.set(key, args[2]); }
|
||||
@@ -1629,22 +1774,29 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
stoneSkin.configure(saved.settings()); abilities.replace(stoneSkin.definition()); stoneSkinSettings.publish(saved);
|
||||
String effectiveBefore = failure == null ? stoneSkinSettings.value(key) : null;
|
||||
boolean applied = failure == null && stoneSkinSettings.publish(saved);
|
||||
if (applied) {
|
||||
stoneSkin.configure(stoneSkinSettings.settings()); abilities.replace(stoneSkin.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "stone_skin", key, effectiveBefore, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Stone Skin casts; active effects and deadlines are unchanged."
|
||||
: "Stone Skin configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Stone Skin configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Stone Skin casts; active effects and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + stoneSkinSettings.value(key) + ".");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void secondWindConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = secondWindSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + secondWindSettings.value(key) + " (applies to future Second Wind casts only)");
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "second_wind", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Second Wind casts only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SecondWindSettings.Update> operation;
|
||||
@@ -1658,15 +1810,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
secondWind.configure(saved.settings()); abilities.replace(secondWind.definition());
|
||||
secondWindSettings.publish(saved);
|
||||
String effectiveBefore = failure == null ? secondWindSettings.value(key) : null;
|
||||
boolean applied = failure == null && secondWindSettings.publish(saved);
|
||||
if (applied) {
|
||||
secondWind.configure(secondWindSettings.settings()); abilities.replace(secondWind.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "second_wind", key, effectiveBefore, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Second Wind casts; active work and deadlines are unchanged."
|
||||
: "Second Wind configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Second Wind configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Second Wind casts; active work and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + secondWindSettings.value(key) + ".");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
@@ -1720,9 +1875,72 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fstickadmin config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void switchDelayConfiguration(CommandSender sender,String[] args){if(switchDelaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+switchDelaySettings.value()+" (new exits use this delay; existing deadlines are unchanged)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;try{operation=switchDelaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-switch-delay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)switchDelaySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()+"; new exits use it and existing deadlines are unchanged.":"Character switch delay save failed; the effective value is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
private void switchDelayConfiguration(CommandSender sender,String[] args){
|
||||
if(switchDelaySettings==null){configurationUsage(sender);return;}
|
||||
var audit=switchConfigEvents;
|
||||
if(args.length==2){
|
||||
var effective=switchDelaySettings.value();
|
||||
if(audit!=null)audit.read(audit.actor(sender),effective);
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+effective
|
||||
+" (new exits use this delay; existing deadlines are unchanged)");
|
||||
return;
|
||||
}
|
||||
var actor=audit==null?null:audit.actor(sender);
|
||||
var operationId=java.util.UUID.randomUUID();
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;
|
||||
try{operation=switchDelaySettings.set(args[2]);}
|
||||
catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}
|
||||
operation.whenComplete((saved,failure)->{
|
||||
if(failure!=null)report(null,"config-switch-delay",failure);
|
||||
if(closed)return;
|
||||
try{nextTick.accept(()->{
|
||||
if(closed)return;
|
||||
if(failure==null){
|
||||
var before=switchDelaySettings.value();
|
||||
switchDelaySettings.publish(saved);
|
||||
var effective=switchDelaySettings.value();
|
||||
if(audit!=null&&!before.equals(effective))audit.changed(actor,operationId,before,effective,saved);
|
||||
}
|
||||
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
|
||||
sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()
|
||||
+"; new exits use it and existing deadlines are unchanged."
|
||||
:"Character switch delay save failed; the effective value is unchanged.");
|
||||
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
|
||||
});
|
||||
}
|
||||
|
||||
private void adminReviveConfiguration(CommandSender sender,String[] args){if(adminReviveSettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+adminReviveSettings.value()+" (applies to later admin revival selections only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdminReviveSettings.Update> operation;try{operation=adminReviveSettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Admin revival timeout is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-admin-revive",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)adminReviveSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+saved.value()+" for later selections; open selections retain their deadline.":"Admin revival configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
private void adminReviveConfiguration(CommandSender sender,String[] args){
|
||||
if(adminReviveSettings==null){configurationUsage(sender);return;}
|
||||
var audit = adminRevivalEvents;
|
||||
if(args.length==2){
|
||||
var value=adminReviveSettings.value();
|
||||
if(audit!=null)audit.read(audit.actor(sender),value);
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+value
|
||||
+" (applies to later admin revival selections only)");
|
||||
return;
|
||||
}
|
||||
var actor=audit==null?null:audit.actor(sender);
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdminReviveSettings.Update> operation;
|
||||
try{operation=adminReviveSettings.set(args[2]);}
|
||||
catch(IllegalArgumentException invalid){sender.sendMessage("Admin revival timeout is invalid; the effective value is unchanged.");return;}
|
||||
operation.whenComplete((saved,failure)->{
|
||||
if(failure!=null)report(null,"config-admin-revive",failure);
|
||||
if(closed)return;
|
||||
try{nextTick.accept(()->{
|
||||
if(closed)return;
|
||||
if(failure==null){
|
||||
var before=adminReviveSettings.value();
|
||||
adminReviveSettings.publish(saved);
|
||||
if(audit!=null && !before.equals(adminReviveSettings.value()))
|
||||
audit.changed(actor,before,saved);
|
||||
}
|
||||
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
|
||||
sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+saved.value()
|
||||
+" for later selections; open selections retain their deadline."
|
||||
:"Admin revival configuration save failed; the effective setting is unchanged.");
|
||||
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
|
||||
});
|
||||
}
|
||||
|
||||
private void adventureConfiguration(CommandSender sender, String[] args) {
|
||||
var action = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, null);
|
||||
@@ -1845,6 +2063,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY);
|
||||
keys.add(games.dmg.fantasystick.persistence.HealthStealSettings.KEY);
|
||||
keys.addAll(games.dmg.fantasystick.persistence.FireballSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.GreatSwordSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
|
||||
@@ -1909,6 +2128,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
String defaults = games.dmg.fantasystick.persistence.FireballSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (greatSwordSettings != null && games.dmg.fantasystick.persistence.GreatSwordSettings.keys().contains(args[1])) {
|
||||
String current = greatSwordSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.GreatSwordSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (frostSettings != null && games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
|
||||
String current = frostSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(args[1]);
|
||||
|
||||
@@ -48,6 +48,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> downedRewardAllocation=ignored->List.of();
|
||||
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
|
||||
private games.dmg.fantasystick.events.SessionEventBridge sessionEvents;
|
||||
private games.dmg.fantasystick.events.NutritionEventBridge nutritionEvents;
|
||||
private NutritionPolicy nutritionPolicy;
|
||||
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchEvents;
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
|
||||
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentEvents;
|
||||
@@ -122,6 +124,10 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean exitRequested;
|
||||
games.dmg.fantasystick.events.SessionEventBridge.ExitCapture exitEvent;
|
||||
boolean exitEventWritten;
|
||||
games.dmg.fantasystick.events.NutritionEventBridge.Capture nutritionBeforeRestore;
|
||||
games.dmg.fantasystick.events.NutritionEventBridge.Capture nutritionAfterRestore;
|
||||
boolean nutritionRestoreWritten;
|
||||
boolean nutritionRetainedWritten;
|
||||
games.dmg.fantasystick.events.SwitchDelayEventBridge.ExitContext switchExit;
|
||||
boolean switchEventWritten;
|
||||
String exitReason = "voluntary";
|
||||
@@ -230,6 +236,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public synchronized void attachDownedRewardAllocation(java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> allocation){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedRewardAllocation=Objects.requireNonNull(allocation);}
|
||||
public synchronized void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(!owned.isEmpty()||downedEvents!=null)throw new IllegalStateException("Downed events already attached or sessions started");downedEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachSessionEvents(games.dmg.fantasystick.events.SessionEventBridge events){if(!owned.isEmpty()||sessionEvents!=null)throw new IllegalStateException("Session events already attached or sessions started");sessionEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachNutritionEvents(games.dmg.fantasystick.events.NutritionEventBridge events){if(!owned.isEmpty()||nutritionEvents!=null)throw new IllegalStateException("Nutrition events already attached or sessions started");nutritionEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachNutritionPolicy(NutritionPolicy policy){if(!owned.isEmpty()||nutritionPolicy!=null)throw new IllegalStateException("Nutrition policy already attached or sessions started");nutritionPolicy=Objects.requireNonNull(policy);}
|
||||
public synchronized void attachSwitchEvents(games.dmg.fantasystick.events.SwitchDelayEventBridge events){if(!owned.isEmpty()||switchEvents!=null)throw new IllegalStateException("Switch events already attached or sessions started");switchEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events){if(!owned.isEmpty()||identityEvents!=null)throw new IllegalStateException("Identity events already attached or sessions started");identityEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachEquipmentEvents(games.dmg.fantasystick.events.EquipmentEventBridge events){if(!owned.isEmpty()||equipmentEvents!=null)throw new IllegalStateException("Equipment events already attached or sessions started");equipmentEvents=Objects.requireNonNull(events);}
|
||||
@@ -748,6 +756,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
attempt.player.updateInventory();
|
||||
if(active.get(attempt.owner)==attempt&&sessionEvents!=null)sessionEvents.entered(attempt.player,attempt.session,record,attempt.entryLocation,attempt.resumedDowned!=null,attempt.previousGameMode!=null);
|
||||
if(active.get(attempt.owner)==attempt&&nutritionEvents!=null)nutritionEvents.suspended(
|
||||
attempt.player, attempt.session, record, attempt.ordinaryVitals, attempt.packReceiptRevision);
|
||||
if(active.get(attempt.owner)==attempt&&identityEvents!=null)identityEvents.entered(attempt.player,attempt.session,record);
|
||||
} catch (RuntimeException error) {
|
||||
fail(attempt, error); // Effects may have started: never roll back or replay them.
|
||||
@@ -794,6 +804,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
return EquipmentStorage.RestoreResult.NOT_FOUND;
|
||||
}
|
||||
cancelEntry(owner);
|
||||
var current = active.get(owner);
|
||||
if (current != null && nutritionPolicy != null) nutritionPolicy.flushNativeHealing(current.player);
|
||||
var attempt = active.remove(owner);
|
||||
if (attempt == null) { invalidateCursor(owner); return EquipmentStorage.RestoreResult.NOT_FOUND; }
|
||||
attempt.exitReason = Objects.requireNonNull(reason);
|
||||
@@ -1061,6 +1073,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if(sessionEvents!=null&&attempt.exitEvent==null&&!attempt.defeated&&attempt.downed==null&&!closed&&attempt.player.isOnline()&&attempt.record!=null)
|
||||
attempt.exitEvent=sessionEvents.captureExit(attempt.player,attempt.session,attempt.record,attempt.entryLocation);
|
||||
captureExitDelay(attempt);
|
||||
if (nutritionPolicy != null) nutritionPolicy.flushNativeHealing(attempt.player);
|
||||
active.remove(attempt.owner, attempt);
|
||||
withdrawPresentation(attempt);
|
||||
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
|
||||
@@ -1201,6 +1214,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
|
||||
private void recoveryIncomplete(Attempt attempt, String reason) {
|
||||
if (attempt.recoveryResolved && attempt.sessionRecoveryResolved) return;
|
||||
nutritionRetained(attempt, reason);
|
||||
if (equipmentEvents != null && attempt.recoveryAction != null && !attempt.recoveryResolved) {
|
||||
attempt.recoveryResolved = true;
|
||||
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
|
||||
@@ -1229,11 +1243,34 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
|
||||
}
|
||||
|
||||
private void nutritionRetained(Attempt attempt, String reason) {
|
||||
if (nutritionEvents == null || attempt.ordinaryVitals == null || attempt.nutritionRetainedWritten) return;
|
||||
var captured = attempt.nutritionBeforeRestore == null
|
||||
? nutritionEvents.capture(attempt.player, attempt.session, attempt.character,
|
||||
attempt.record == null ? null : attempt.record.name(), attempt.exitReason)
|
||||
: attempt.nutritionBeforeRestore;
|
||||
if (captured == null) return;
|
||||
attempt.nutritionRetainedWritten = true;
|
||||
nutritionEvents.restorationRetained(captured, reason);
|
||||
}
|
||||
|
||||
private boolean restoreVitals(Attempt attempt) {
|
||||
if (vitals == null || attempt.ordinaryVitals == null) return true;
|
||||
boolean applied = nutritionEvents != null && vitals.nutritionRestorationReady(
|
||||
attempt.player, attempt.session, attempt.ordinaryVitals);
|
||||
var before = nutritionEvents == null ? null : nutritionEvents.capture(attempt.player,
|
||||
attempt.session, attempt.character, attempt.record == null ? null : attempt.record.name(), attempt.exitReason);
|
||||
try {
|
||||
if (vitals.restore(attempt.player, attempt.session, attempt.ordinaryVitals)) return true;
|
||||
if (vitals.restore(attempt.player, attempt.session, attempt.ordinaryVitals)) {
|
||||
if (applied && before != null) {
|
||||
attempt.nutritionBeforeRestore = before;
|
||||
attempt.nutritionAfterRestore = nutritionEvents.capture(attempt.player, attempt.session,
|
||||
attempt.character, attempt.record == null ? null : attempt.record.name(), attempt.exitReason);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
|
||||
nutritionRetained(attempt, "native_restoration_unverified");
|
||||
notice(attempt.owner, "VITALS_RECOVERY", "Health/nutrition restoration was refused or ambiguous; retained receipt requires inspection.");
|
||||
return false;
|
||||
}
|
||||
@@ -1255,6 +1292,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
: receipts.recordExit(attempt.owner, attempt.session, attempt.exitDelay), (state, failure) -> {
|
||||
attempt.saving = false;
|
||||
if (failure != null) {
|
||||
nutritionRetained(attempt, "receipt_save_failed");
|
||||
sessionReconciled(attempt,
|
||||
attempt.nativeRestored ? "restored" : "pending",
|
||||
attempt.nativeRestored ? "ambiguous" : "failed",
|
||||
@@ -1287,14 +1325,21 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
attempt.switchEventWritten = true;
|
||||
switchEvents.started(attempt.switchExit, state);
|
||||
}
|
||||
if (!attempt.nutritionRestoreWritten && nutritionEvents != null
|
||||
&& attempt.nutritionAfterRestore != null) {
|
||||
attempt.nutritionRestoreWritten = true;
|
||||
nutritionEvents.restored(attempt.nutritionBeforeRestore, attempt.nutritionAfterRestore,
|
||||
attempt.ordinaryVitals, state.revision());
|
||||
}
|
||||
if (!attempt.defeated && !attempt.exitEventWritten && attempt.exitEvent != null && sessionEvents != null) {
|
||||
attempt.exitEventWritten = true;
|
||||
sessionEvents.exited(attempt.session,attempt.exitEvent);
|
||||
}
|
||||
if (attempt.defeated) queueTail(attempt);
|
||||
release(attempt);
|
||||
} else if (attempt.defeated) {
|
||||
conversionUncertain(attempt,
|
||||
} else {
|
||||
nutritionRetained(attempt, !attempt.healthAcknowledged ? "health_unverified" : "receipt_incomplete");
|
||||
if (attempt.defeated) conversionUncertain(attempt,
|
||||
"Equipment restoration was not acknowledged; Tail conversion remains unsafe");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,10 +9,39 @@ import org.bukkit.event.Listener;
|
||||
/** Active-session-only hunger/exhaustion suppression; explicit non-food healing remains native. */
|
||||
public final class NutritionPolicy implements Listener, AutoCloseable {
|
||||
private final EquipmentSessions sessions;
|
||||
private final java.util.function.Consumer<Runnable> nextTick;
|
||||
private final java.util.Set<org.bukkit.event.entity.EntityRegainHealthEvent> owned =
|
||||
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
|
||||
private interface PendingHealing extends Runnable { void flush(); }
|
||||
private final java.util.Map<java.util.UUID, PendingHealing> pendingNativeHealing = new java.util.HashMap<>();
|
||||
private final java.util.Set<java.util.UUID> ambiguousNativeHealing = new java.util.HashSet<>();
|
||||
private final java.util.Set<org.bukkit.event.entity.EntityRegainHealthEvent> suppressed =
|
||||
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
|
||||
private games.dmg.fantasystick.events.NutritionEventBridge events;
|
||||
private boolean closed;
|
||||
|
||||
public NutritionPolicy(EquipmentSessions sessions) {
|
||||
this(sessions, ignored -> { });
|
||||
}
|
||||
|
||||
public NutritionPolicy(EquipmentSessions sessions, java.util.function.Consumer<Runnable> nextTick) {
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
}
|
||||
|
||||
/** Explicit provenance for a plugin-owned heal that another feature owner audits. */
|
||||
public void dispatchOwnedHealing(org.bukkit.event.entity.EntityRegainHealthEvent event,
|
||||
java.util.function.Consumer<? super org.bukkit.event.entity.EntityRegainHealthEvent> dispatcher) {
|
||||
if (event.getEntity() instanceof Player player && pendingNativeHealing.containsKey(player.getUniqueId()))
|
||||
ambiguousNativeHealing.add(player.getUniqueId());
|
||||
owned.add(event);
|
||||
try { dispatcher.accept(event); }
|
||||
finally { owned.remove(event); }
|
||||
}
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.NutritionEventBridge bridge) {
|
||||
if (events != null) throw new IllegalStateException("Nutrition events already attached");
|
||||
events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
@@ -36,7 +65,74 @@ public final class NutritionPolicy implements Listener, AutoCloseable {
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void healing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
|
||||
if (event.getRegainReason() == org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED
|
||||
&& active(event.getEntity())) event.setCancelled(true);
|
||||
&& active(event.getEntity())) {
|
||||
event.setCancelled(true);
|
||||
suppressed.add(event);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void resolvedHealing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
|
||||
boolean blocked = suppressed.remove(event);
|
||||
if (events == null || !(event.getEntity() instanceof Player player)) return;
|
||||
var active = sessions.active(player.getUniqueId()).orElse(null);
|
||||
if (active == null) return;
|
||||
var record = sessions.currentRecord(active).orElse(null);
|
||||
if (blocked) {
|
||||
if (event.isCancelled()) events.satiatedRefused(player, active.session(), active.character(),
|
||||
record == null ? null : record.name(), event.getAmount());
|
||||
return;
|
||||
}
|
||||
if (event.isCancelled() || owned.contains(event) || !Double.isFinite(event.getAmount())
|
||||
|| event.getAmount() <= 0 || record == null) return;
|
||||
var owner = player.getUniqueId();
|
||||
if (pendingNativeHealing.containsKey(owner)) {
|
||||
ambiguousNativeHealing.add(owner);
|
||||
return;
|
||||
}
|
||||
double before = player.getHealth();
|
||||
double accepted = event.getAmount();
|
||||
String source = event.getRegainReason().name();
|
||||
var captured = events.capture(player, active.session(), active.character(), record.name(), "native_regain");
|
||||
PendingHealing completion = new PendingHealing() {
|
||||
@Override public void run() { finish(false); }
|
||||
@Override public void flush() { finish(true); }
|
||||
private void finish(boolean exiting) {
|
||||
if (!pendingNativeHealing.remove(owner, this)) return;
|
||||
boolean ambiguous = ambiguousNativeHealing.remove(owner);
|
||||
if (closed || ambiguous || captured == null) return;
|
||||
try {
|
||||
boolean stillOwned = exiting
|
||||
? sessions.active(owner).filter(active::equals).isPresent() && player.isOnline()
|
||||
: sessions.current(active);
|
||||
if (!stillOwned) return;
|
||||
double after = player.getHealth();
|
||||
double delta = after - before;
|
||||
if (Double.isFinite(delta) && delta > 0 && delta <= accepted)
|
||||
events.nativeHealed(captured, before, after, source);
|
||||
} catch (RuntimeException ignored) {
|
||||
// An audit observation cannot interrupt native healing or session exit.
|
||||
}
|
||||
}
|
||||
};
|
||||
pendingNativeHealing.put(owner, completion);
|
||||
try { nextTick.accept(completion); }
|
||||
catch (RuntimeException failure) { pendingNativeHealing.remove(owner, completion); }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void concurrentDamage(org.bukkit.event.entity.EntityDamageEvent event) {
|
||||
if (event.getEntity() instanceof Player player && pendingNativeHealing.containsKey(player.getUniqueId()))
|
||||
ambiguousNativeHealing.add(player.getUniqueId());
|
||||
}
|
||||
|
||||
/** Complete a proved native heal before the owning session is released in the same tick. */
|
||||
public void flushNativeHealing(Player player) {
|
||||
var pending = pendingNativeHealing.get(player.getUniqueId());
|
||||
if (pending != null) {
|
||||
try { pending.flush(); }
|
||||
catch (RuntimeException ignored) { /* Never interrupt native exit for an audit. */ }
|
||||
}
|
||||
}
|
||||
|
||||
private boolean active(org.bukkit.entity.Entity entity) {
|
||||
@@ -44,5 +140,5 @@ public final class NutritionPolicy implements Listener, AutoCloseable {
|
||||
&& sessions.active(player.getUniqueId()).isPresent();
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; }
|
||||
@Override public void close() { closed = true; suppressed.clear(); owned.clear(); pendingNativeHealing.clear(); ambiguousNativeHealing.clear(); events = null; }
|
||||
}
|
||||
@@ -106,6 +106,13 @@ public final class SessionVitals implements AutoCloseable {
|
||||
return BigDecimal.valueOf(Math.max(0, Math.min(player.getHealth(), mark.maximum())));
|
||||
}
|
||||
|
||||
/** Only an applied marker permits a newly observed native nutrition restoration. */
|
||||
public boolean nutritionRestorationReady(Player player, UUID session, OrdinaryVitals ordinary) {
|
||||
var mark = read(player);
|
||||
return ordinary != null && mark != null && mark.session().equals(session)
|
||||
&& mark.ordinary().equals(ordinary) && mark.phase() == Phase.APPLIED;
|
||||
}
|
||||
|
||||
public boolean restore(Player player, UUID session, OrdinaryVitals ordinary) {
|
||||
if (ordinary == null) return true; // Legacy equipment-only receipt, not health restoration authority.
|
||||
var mark = read(player);
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
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 games.dmg.fantasystick.items.CharacterSheets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Registered administrator issuance is not an event success until native delivery is acknowledged. */
|
||||
class AdminCharacterIssueEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredConsoleIssueExportsOnlyAcknowledgedSavedAndDeliveredSheet() throws Exception {
|
||||
UUID issued;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(target.core.id, target.player);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
|
||||
assertTrue(sender.messages.isEmpty(), "A queued save is not delivery");
|
||||
assertTrue(issuedEvents().isEmpty(), "A queued save cannot export issuance");
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
var completion = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(completion);
|
||||
completion.run(); completion.run();
|
||||
assertTrue(sender.messages.getLast().contains("Issued one new character sheet"));
|
||||
var sheets = new CharacterSheets();
|
||||
var delivered = java.util.stream.IntStream.range(0, 36)
|
||||
.filter(slot -> sheets.identify(target.core.inventory.getItem(slot)).isPresent()
|
||||
&& !sheets.identify(target.core.inventory.getItem(slot)).orElseThrow().equals(target.core.record.id()))
|
||||
.toArray();
|
||||
assertEquals(1, delivered.length);
|
||||
issued = sheets.identify(target.core.inventory.getItem(delivered[0])).orElseThrow();
|
||||
var saved = storage.find(issued).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
for (int i = 0; i < 200 && issuedEvents().isEmpty(); i++) Thread.sleep(10);
|
||||
var matching = issuedEvents().stream().filter(event -> event.getAsJsonObject("data")
|
||||
.get("character_id").getAsString().equals(issued.toString())).toList();
|
||||
assertEquals(1, matching.size(), "One native delivery has one stable success record");
|
||||
var event = matching.getFirst(); var payload = event.getAsJsonObject("data");
|
||||
assertEquals("console", payload.get("actor_kind").getAsString());
|
||||
assertEquals(target.core.id.toString(), payload.get("target_player_id").getAsString());
|
||||
assertEquals(target.accountName, payload.get("target_player_name").getAsString());
|
||||
assertEquals(issued.toString(), payload.get("character_id").getAsString());
|
||||
assertEquals(saved.name(), payload.get("character_name").getAsString());
|
||||
assertEquals(saved.revision().toString(), payload.get("revision").getAsString());
|
||||
assertEquals(saved.unspentPoints().toString(), payload.get("starting_unspent_points").getAsString());
|
||||
assertEquals(saved.stats().size(), payload.getAsJsonObject("starting_stats").size());
|
||||
assertEquals(delivered[0], payload.get("delivered_slot").getAsInt());
|
||||
assertEquals("acknowledged_delivered", payload.get("outcome").getAsString());
|
||||
assertEquals("not_applicable", payload.get("actor_location_status").getAsString());
|
||||
assertEquals(target.world.getUID().toString(),
|
||||
payload.getAsJsonObject("target_location").get("world_id").getAsString());
|
||||
assertEquals(payload.get("issue_operation_id").getAsString(), payload.get("action_id").getAsString());
|
||||
assertEquals(1, matching.stream().map(item -> item.get("id").getAsString()).distinct().count());
|
||||
}
|
||||
var beforeRestart = issuedEvents().stream().filter(event -> event.getAsJsonObject("data")
|
||||
.get("character_id").getAsString().equals(issued.toString())).toList();
|
||||
assertEquals(1, beforeRestart.size());
|
||||
try (var restarted = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
restarted.plugin.setEnabled(true);
|
||||
restarted.plugin.setEnabled(false);
|
||||
}
|
||||
var afterRestart = issuedEvents().stream().filter(event -> event.getAsJsonObject("data")
|
||||
.get("character_id").getAsString().equals(issued.toString())).toList();
|
||||
assertEquals(1, afterRestart.stream().map(item -> item.get("id").getAsString()).distinct().count());
|
||||
}
|
||||
|
||||
@Test void registeredPlayerAdministratorCapturesActualActorAndTargetLocations() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
admin.accountName = "Administrator";
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(admin.core.id, admin.player); f.players.put(target.core.id, target.player);
|
||||
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"give", target.accountName});
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
var completion = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(completion); completion.run();
|
||||
for (int i = 0; i < 200 && issuedEvents().isEmpty(); i++) Thread.sleep(10);
|
||||
var events = issuedEvents(); assertEquals(1, events.size());
|
||||
var data = events.getFirst().getAsJsonObject("data");
|
||||
assertEquals(admin.core.id.toString(), data.get("player_id").getAsString());
|
||||
assertEquals("player", data.get("actor_kind").getAsString());
|
||||
assertEquals(admin.world.getUID().toString(), data.getAsJsonObject("location").get("world_id").getAsString());
|
||||
assertEquals(target.world.getUID().toString(), data.getAsJsonObject("target_location").get("world_id").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void fullInventoryAndDeniedCommandCannotExportIssuance() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(target.core.id, target.player);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
sender.allowed = false;
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
|
||||
sender.allowed = true;
|
||||
for (int slot = 0; slot < 36; slot++)
|
||||
target.core.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
|
||||
assertTrue(sender.messages.getLast().contains("no inventory space"));
|
||||
assertTrue(f.tasks.isEmpty());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(issuedEvents().isEmpty());
|
||||
}
|
||||
|
||||
@Test void failedSaveDisconnectAndCloseCannotExportIssuance() throws Exception {
|
||||
for (var route : List.of("save", "disconnect", "close")) {
|
||||
var dir = data.resolve(route); Files.createDirectories(dir);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(dir)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(dir);
|
||||
f.players.put(target.core.id, target.player);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var worker = (java.util.concurrent.ExecutorService)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(storage, "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
worker.execute(() -> {
|
||||
entered.countDown();
|
||||
try { release.await(5, TimeUnit.SECONDS); }
|
||||
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
|
||||
});
|
||||
try {
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
|
||||
if (route.equals("save")) {
|
||||
var issuer = FantasyStickLifecycleNativeTest.Fixture.field(f.command.getExecutor(), "characterIssuer");
|
||||
var pending = (java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(issuer, "pending");
|
||||
var attempt = pending.values().iterator().next();
|
||||
var record = (games.dmg.fantasystick.persistence.CharacterRecord)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(attempt, "record");
|
||||
Files.createDirectory(dir.resolve("characters").resolve(record.id() + ".yml"));
|
||||
}
|
||||
} finally { release.countDown(); }
|
||||
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback);
|
||||
if (route.equals("disconnect")) target.core.online = false;
|
||||
if (route.equals("close")) f.plugin.setEnabled(false);
|
||||
callback.run();
|
||||
assertFalse(sender.messages.stream().anyMatch(message -> message.startsWith("Issued one")));
|
||||
if (!route.equals("close")) f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(issuedEvents(dir).isEmpty(), route + " is not acknowledged delivery");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unavailableEventStoreCannotUndoAcknowledgedNativeIssue() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
Files.writeString(data.resolve("character-identity-event-intents"), "obstructed");
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(target.core.id, target.player);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
assertTrue(sender.messages.getLast().contains("Issued one new character sheet"));
|
||||
var sheets = new CharacterSheets();
|
||||
assertEquals(1, java.util.stream.IntStream.range(0, 36).filter(slot ->
|
||||
sheets.identify(target.core.inventory.getItem(slot)).filter(id -> !id.equals(target.core.record.id()))
|
||||
.isPresent()).count());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(issuedEvents().isEmpty(), "A failed audit store never fabricates an exported record");
|
||||
}
|
||||
|
||||
@Test void staleReservedSlotAndFailedNativeSetterCannotClaimAnIssuedSheet() throws Exception {
|
||||
for (var route : List.of("slot", "native")) {
|
||||
var dir = data.resolve(route); Files.createDirectories(dir);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(dir)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(dir);
|
||||
f.players.put(target.core.id, target.player);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
if (route.equals("native")) {
|
||||
var inventory = mock(org.bukkit.inventory.PlayerInventory.class);
|
||||
when(inventory.firstEmpty()).thenReturn(1);
|
||||
doThrow(new IllegalStateException("native setter rejected"))
|
||||
.when(inventory).setItem(eq(1), any(org.bukkit.inventory.ItemStack.class));
|
||||
doReturn(inventory).when(target.player).getInventory();
|
||||
}
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback);
|
||||
if (route.equals("slot"))
|
||||
target.core.inventory.setItem(1, new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
|
||||
callback.run();
|
||||
assertFalse(sender.messages.stream().anyMatch(message -> message.startsWith("Issued one")));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(issuedEvents(dir).isEmpty(), route + " must not claim confirmed delivery");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectedEventStoreRetainsTheSameIssuanceIdentityForReplayWithoutRedelivery() throws Exception {
|
||||
var target = mock(org.bukkit.entity.Player.class);
|
||||
var playerId = UUID.randomUUID();
|
||||
when(target.getUniqueId()).thenReturn(playerId); when(target.getName()).thenReturn("Target");
|
||||
var sender = mock(org.bukkit.command.CommandSender.class);
|
||||
var record = new games.dmg.fantasystick.crafting.CharacterFactory()
|
||||
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var initial = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
|
||||
ignored -> java.util.concurrent.CompletableFuture.completedFuture(
|
||||
games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED),
|
||||
java.time.Clock.systemUTC(), data, ignored -> java.util.Optional.empty(), failure -> fail(failure));
|
||||
var issue = initial.issue(sender, target);
|
||||
assertNotNull(issue);
|
||||
initial.issued(issue, target, record, 1);
|
||||
initial.close(); initial.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
try (var pending = Files.list(data.resolve("character-identity-event-intents"))) {
|
||||
assertEquals(1, pending.filter(path -> path.toString().endsWith(".pending")).count());
|
||||
}
|
||||
var exported = new java.util.concurrent.CopyOnWriteArrayList<games.dmg.fantasystick.events.EventInput>();
|
||||
var replay = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(event -> {
|
||||
exported.add(event);
|
||||
return java.util.concurrent.CompletableFuture.completedFuture(
|
||||
games.dmg.fantasystick.events.LocalEventLog.Delivery.EXPORTED);
|
||||
}, java.time.Clock.systemUTC(), data, ignored -> java.util.Optional.empty(), failure -> fail(failure));
|
||||
replay.replay(); replay.close(); replay.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(1, exported.size());
|
||||
assertEquals(issue.operation(), exported.getFirst().actionId());
|
||||
assertEquals(issue.operation().toString(), exported.getFirst().data().get("issue_operation_id"));
|
||||
assertEquals(playerId.toString(), exported.getFirst().data().get("target_player_id"));
|
||||
assertEquals(1, exported.getFirst().data().get("delivered_slot"));
|
||||
assertEquals(record.stats().size(), ((java.util.Map<?, ?>) exported.getFirst().data().get("starting_stats")).size());
|
||||
assertEquals("unavailable", exported.getFirst().data().get("target_location_status"));
|
||||
var again = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(event -> {
|
||||
exported.add(event);
|
||||
return java.util.concurrent.CompletableFuture.completedFuture(
|
||||
games.dmg.fantasystick.events.LocalEventLog.Delivery.EXPORTED);
|
||||
}, java.time.Clock.systemUTC(), data, ignored -> java.util.Optional.empty(), failure -> fail(failure));
|
||||
again.replay(); again.close(); again.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(1, exported.size(), "Acknowledged export removes the recoverable intent");
|
||||
}
|
||||
|
||||
private List<JsonObject> issuedEvents() throws Exception { return issuedEvents(data); }
|
||||
|
||||
private List<JsonObject> issuedEvents(Path root) throws Exception {
|
||||
var results = new ArrayList<JsonObject>();
|
||||
if (!Files.isDirectory(root.resolve("events"))) return results;
|
||||
try (var days = Files.list(root.resolve("events"))) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}"))
|
||||
.toList()) {
|
||||
try (var segments = Files.list(day)) {
|
||||
for (var path : segments.filter(file -> file.getFileName().toString().startsWith("events-")
|
||||
&& file.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
for (var line : Files.readAllLines(path)) {
|
||||
if (!line.isBlank()) {
|
||||
var event = JsonParser.parseString(line).getAsJsonObject();
|
||||
if (event.get("type").getAsString().endsWith("character.issued")) results.add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
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.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class AdminRevivalEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredSelectionPublishesOneAcknowledgedAdminTailRestorationNotMenuOrRequest() throws Exception {
|
||||
UUID character;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
doReturn("Administrator").when(admin.player).getName();
|
||||
doReturn("Target").when(target.player).getName();
|
||||
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
|
||||
f.players.put(admin.core.id, admin.player); f.players.put(target.core.id, target.player);
|
||||
f.activate(target);
|
||||
var active = ((games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"))
|
||||
.active(target.core.id).orElseThrow();
|
||||
var dead = AdminRevivalNativeTest.dead(data, Instant.now().minusSeconds(172800)
|
||||
.truncatedTo(ChronoUnit.SECONDS));
|
||||
character = dead.id();
|
||||
target.core.inventory.setItem(10, new games.dmg.fantasystick.items.TailBooks().create(dead));
|
||||
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"revive", "Target"});
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
storage.find(dead.id()).get(5, TimeUnit.SECONDS); f.tick();
|
||||
assertEquals(1, admin.inventories.size());
|
||||
assertTrue(revivals().isEmpty(), "Opening the selection is not a revival");
|
||||
var popup = admin.inventories.getFirst();
|
||||
var view = mock(org.bukkit.inventory.InventoryView.class);
|
||||
when(view.getTopInventory()).thenReturn(popup);
|
||||
when(view.getBottomInventory()).thenReturn(admin.core.inventory);
|
||||
when(view.getPlayer()).thenReturn(admin.player);
|
||||
var click = new org.bukkit.event.inventory.InventoryClickEvent(view, InventoryType.SlotType.CONTAINER,
|
||||
0, ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
f.dispatcher.accept(click); assertTrue(click.isCancelled());
|
||||
PartyBookNativeTest.drain(f);
|
||||
var reservations = (games.dmg.fantasystick.persistence.RevivalStore)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals");
|
||||
reservations.find(dead.id()).join();
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
|
||||
var saved = storage.find(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertFalse(saved.dead());
|
||||
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
|
||||
.identify(target.core.inventory.getItem(10)).orElseThrow());
|
||||
for (int i = 0; i < 200 && revivals().isEmpty(); i++) Thread.sleep(10);
|
||||
var own = revivals(); assertEquals(1, own.size());
|
||||
var event = own.getFirst(); var payload = event.getAsJsonObject("data");
|
||||
assertEquals("admin_tail", payload.get("revival_kind").getAsString());
|
||||
assertEquals(admin.core.id.toString(), payload.get("player_id").getAsString());
|
||||
assertEquals(target.core.id.toString(), payload.get("target_player_id").getAsString());
|
||||
assertEquals(dead.id().toString(), payload.get("character_id").getAsString());
|
||||
assertEquals(dead.deathId().toString(), payload.get("death_id").getAsString());
|
||||
assertEquals(dead.revision().toString(), payload.get("revision_before").getAsString());
|
||||
assertEquals(saved.revision().toString(), payload.get("revision_after").getAsString());
|
||||
assertEquals(10, payload.get("tail_slot").getAsInt());
|
||||
assertEquals(active.session().toString(), payload.get("target_session_id").getAsString());
|
||||
assertEquals(active.character().toString(), payload.get("target_active_character_id").getAsString());
|
||||
assertEquals("acknowledged", payload.get("outcome").getAsString());
|
||||
assertEquals(admin.world.getUID().toString(), payload.getAsJsonObject("location").get("world_id").getAsString());
|
||||
assertEquals(target.world.getUID().toString(), payload.getAsJsonObject("target_location").get("world_id").getAsString());
|
||||
f.dispatcher.accept(click);
|
||||
assertEquals(1, revivals().size(), "Repeated selection cannot create another revival");
|
||||
}
|
||||
assertEquals(1, revivals().stream().filter(event -> event.getAsJsonObject("data")
|
||||
.get("character_id").getAsString().equals(character.toString())).count());
|
||||
}
|
||||
|
||||
@Test void registeredTimeoutReadAndAcknowledgedUpdateReachExportedJsonlOnlyAfterSave() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "admin-revive-selection-timeout-seconds"});
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "admin-revive-selection-timeout-seconds", "90"});
|
||||
assertTrue(sender.messages.getLast().contains("=60"), "Queued save cannot claim an update");
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
assertTrue(sender.messages.getLast().contains("=90"));
|
||||
for (int i = 0; i < 200 && ownConfig().size() < 2; i++) Thread.sleep(10);
|
||||
var config = ownConfig();
|
||||
assertEquals(1, config.stream().filter(event -> event.get("type").getAsString().endsWith("config.read")).count());
|
||||
var changed = config.stream().filter(event -> event.get("type").getAsString().endsWith("config.changed")).toList();
|
||||
assertEquals(1, changed.size());
|
||||
assertEquals("60", changed.getFirst().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("90", changed.getFirst().getAsJsonObject("data").get("after").getAsString());
|
||||
assertEquals("later_selections", changed.getFirst().getAsJsonObject("data").get("applies_to").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void queuedAcknowledgedTimeoutChangesDescribeActualEffectiveTransitions() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{
|
||||
"config", "admin-revive-selection-timeout-seconds", "90"});
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{
|
||||
"config", "admin-revive-selection-timeout-seconds", "120"});
|
||||
for (int i = 0; i < 20 && sender.messages.size() < 2; i++) {
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(callback, "Both acknowledged configuration responses need a native callback");
|
||||
callback.run();
|
||||
}
|
||||
assertEquals(2, sender.messages.size(), sender.messages.toString());
|
||||
assertTrue(sender.messages.stream().allMatch(message -> message.startsWith("Saved ")),
|
||||
sender.messages.toString());
|
||||
for (int i = 0; i < 200 && ownConfig().stream().filter(event -> event.get("type")
|
||||
.getAsString().endsWith("config.changed")).count() < 2; i++) Thread.sleep(10);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var changed = ownConfig().stream().filter(event -> event.get("type").getAsString()
|
||||
.endsWith("config.changed")).map(event -> event.getAsJsonObject("data")).toList();
|
||||
assertEquals(2, changed.size());
|
||||
assertEquals(List.of("60", "90"), changed.stream().map(data -> data.get("before").getAsString()).toList());
|
||||
assertEquals(List.of("90", "120"), changed.stream().map(data -> data.get("after").getAsString()).toList());
|
||||
assertEquals(List.of("90", "120"), changed.stream().map(data -> data.get("effective").getAsString()).toList());
|
||||
}
|
||||
|
||||
@Test void failedAuthoritativeRestorationRetainsProofButExportsNoRevival() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
doReturn("Administrator").when(admin.player).getName();
|
||||
doReturn("Target").when(target.player).getName();
|
||||
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
|
||||
f.players.put(admin.core.id, admin.player); f.players.put(target.core.id, target.player);
|
||||
var dead = AdminRevivalNativeTest.dead(data, Instant.now().minusSeconds(172800)
|
||||
.truncatedTo(ChronoUnit.SECONDS));
|
||||
target.core.inventory.setItem(10, new games.dmg.fantasystick.items.TailBooks().create(dead));
|
||||
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"revive", "Target"});
|
||||
PartyBookNativeTest.drain(f);
|
||||
var popup = admin.inventories.getFirst();
|
||||
var corrupted = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
target.core.mutation = () -> {
|
||||
if (!corrupted.compareAndSet(false, true)) return;
|
||||
try {
|
||||
var path = data.resolve("characters").resolve(dead.id() + ".yml");
|
||||
Files.delete(path); Files.createDirectory(path);
|
||||
} catch (Exception error) { throw new RuntimeException(error); }
|
||||
};
|
||||
var view = mock(org.bukkit.inventory.InventoryView.class);
|
||||
when(view.getTopInventory()).thenReturn(popup);
|
||||
when(view.getBottomInventory()).thenReturn(admin.core.inventory);
|
||||
when(view.getPlayer()).thenReturn(admin.player);
|
||||
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
|
||||
InventoryType.SlotType.CONTAINER, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL));
|
||||
PartyBookNativeTest.drain(f);
|
||||
var reservations = (games.dmg.fantasystick.persistence.RevivalStore)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals");
|
||||
reservations.find(dead.id()).join();
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
|
||||
assertTrue(reservations.find(dead.id()).join().isPresent());
|
||||
assertFalse(admin.messages.stream().anyMatch(message -> message.startsWith("Administratively restored")));
|
||||
target.core.mutation = () -> {};
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(revivals().isEmpty(), "A provisional sheet is not authoritative revival");
|
||||
}
|
||||
|
||||
@Test void failedAuditIntentStoreCannotBlockAcknowledgedAdminTailRestoration() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
Files.writeString(data.resolve("admin-revival-event-intents"), "unavailable");
|
||||
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
doReturn("Administrator").when(admin.player).getName();
|
||||
doReturn("Target").when(target.player).getName();
|
||||
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
|
||||
f.players.put(admin.core.id, admin.player); f.players.put(target.core.id, target.player);
|
||||
var dead = AdminRevivalNativeTest.dead(data, Instant.now().minusSeconds(172800)
|
||||
.truncatedTo(ChronoUnit.SECONDS));
|
||||
target.core.inventory.setItem(10, new games.dmg.fantasystick.items.TailBooks().create(dead));
|
||||
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"revive", "Target"});
|
||||
PartyBookNativeTest.drain(f);
|
||||
var popup = admin.inventories.getFirst();
|
||||
var view = mock(org.bukkit.inventory.InventoryView.class);
|
||||
when(view.getTopInventory()).thenReturn(popup);
|
||||
when(view.getBottomInventory()).thenReturn(admin.core.inventory);
|
||||
when(view.getPlayer()).thenReturn(admin.player);
|
||||
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
|
||||
InventoryType.SlotType.CONTAINER, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL));
|
||||
PartyBookNativeTest.drain(f);
|
||||
((games.dmg.fantasystick.persistence.RevivalStore)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals")).find(dead.id()).join();
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
|
||||
var record = ((games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
|
||||
.find(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertFalse(record.dead());
|
||||
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
|
||||
.identify(target.core.inventory.getItem(10)).orElseThrow());
|
||||
assertTrue(admin.messages.stream().anyMatch(message -> message.contains("Administratively restored")));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(revivals().isEmpty(), "An unavailable store cannot assert export or undo gameplay");
|
||||
}
|
||||
|
||||
@Test void confirmedInterruptedAdminReservationReconcilesWithStableIdentityAfterRestart() throws Exception {
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
doReturn("Target").when(target.player).getName();
|
||||
var dead = AdminRevivalNativeTest.dead(data, Instant.now().minusSeconds(172800)
|
||||
.truncatedTo(ChronoUnit.SECONDS));
|
||||
var provisional = new games.dmg.fantasystick.persistence.CharacterRecord(dead.id(), dead.name(),
|
||||
dead.revision(), dead.stats(), dead.level(), dead.experience(), dead.unspentPoints(),
|
||||
dead.maximumHealth(), dead.learned(), dead.loadout(), dead.cooldowns(), dead.restrictedKills(),
|
||||
false, dead.deathId(), dead.diedAt());
|
||||
target.core.inventory.setItem(10, new games.dmg.fantasystick.items.CharacterSheets().create(provisional));
|
||||
var adminId = UUID.randomUUID();
|
||||
var operation = UUID.randomUUID();
|
||||
var reservation = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(operation, adminId,
|
||||
target.core.id, dead.id(), dead.deathId(), Instant.now().truncatedTo(ChronoUnit.SECONDS),
|
||||
10, 1, -1, true);
|
||||
try (var store = new games.dmg.fantasystick.persistence.RevivalStore(data)) {
|
||||
assertTrue(store.reserve(reservation).join().isPresent());
|
||||
}
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.players.put(target.core.id, target.player); f.plugin.setEnabled(true);
|
||||
var store = (games.dmg.fantasystick.persistence.RevivalStore)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals");
|
||||
store.target(target.core.id).join();
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertFalse(storage.find(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow().dead());
|
||||
for (int i = 0; i < 200 && revivals().isEmpty(); i++) Thread.sleep(10);
|
||||
var entries = revivals(); assertEquals(1, entries.size());
|
||||
var event = entries.getFirst(); var values = event.getAsJsonObject("data");
|
||||
assertEquals(operation.toString(), values.get("revival_operation_id").getAsString());
|
||||
assertEquals(adminId.toString(), values.get("admin_player_id").getAsString());
|
||||
assertEquals("system", values.get("actor_kind").getAsString());
|
||||
assertTrue(values.get("recovery_reconciliation").getAsBoolean());
|
||||
assertEquals("unavailable", values.get("actor_location_status").getAsString());
|
||||
assertEquals(dead.id().toString(), values.get("character_id").getAsString());
|
||||
}
|
||||
try (var restarted = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
restarted.players.put(target.core.id, target.player); restarted.plugin.setEnabled(true);
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(restarted);
|
||||
}
|
||||
assertEquals(1, revivals().size(), "Reconciliation and a later restart cannot export duplicate success");
|
||||
}
|
||||
|
||||
@Test void deniedInvalidAndFailedTimeoutUpdatesCannotProduceConfigChanged() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
sender.allowed = false;
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "admin-revive-selection-timeout-seconds", "90"});
|
||||
sender.allowed = true;
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "admin-revive-selection-timeout-seconds", "0"});
|
||||
Files.createDirectory(data.resolve("config.yml"));
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "admin-revive-selection-timeout-seconds", "90"});
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(ownConfig().stream().noneMatch(event -> event.get("type").getAsString().endsWith("config.changed")));
|
||||
}
|
||||
|
||||
@Test void cancelledOrStaleTailSelectionCannotExportRevival() throws Exception {
|
||||
for (var route : List.of("cancel", "movement", "permission")) {
|
||||
var dir = data.resolve(route); Files.createDirectories(dir);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(dir)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new FantasyStickLifecycleNativeTest.Actor(dir);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(dir);
|
||||
doReturn("Administrator").when(admin.player).getName();
|
||||
doReturn("Target").when(target.player).getName();
|
||||
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
|
||||
f.players.put(admin.core.id, admin.player); f.players.put(target.core.id, target.player);
|
||||
var dead = AdminRevivalNativeTest.dead(dir, Instant.now().minusSeconds(172800)
|
||||
.truncatedTo(ChronoUnit.SECONDS));
|
||||
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead);
|
||||
target.core.inventory.setItem(10, tail);
|
||||
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"revive", "Target"});
|
||||
((games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
|
||||
.find(dead.id()).get(5, TimeUnit.SECONDS);
|
||||
f.tick();
|
||||
var popup = admin.inventories.getFirst();
|
||||
if (!route.equals("cancel")) {
|
||||
if (route.equals("movement")) target.core.inventory.setItem(10,
|
||||
new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
|
||||
if (route.equals("permission")) doReturn(false).when(admin.player).hasPermission("fantasystick.admin");
|
||||
var view = mock(org.bukkit.inventory.InventoryView.class);
|
||||
when(view.getTopInventory()).thenReturn(popup);
|
||||
when(view.getBottomInventory()).thenReturn(admin.core.inventory);
|
||||
when(view.getPlayer()).thenReturn(admin.player);
|
||||
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
|
||||
InventoryType.SlotType.CONTAINER, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL));
|
||||
PartyBookNativeTest.drain(f);
|
||||
}
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(events(dir).stream().noneMatch(event -> event.get("type").getAsString()
|
||||
.endsWith("character.revived")), route + " did not restore an acknowledged character");
|
||||
}
|
||||
}
|
||||
|
||||
private List<JsonObject> revivals() throws Exception {
|
||||
return events().stream().filter(event -> event.get("type").getAsString().endsWith("character.revived"))
|
||||
.filter(event -> event.getAsJsonObject("data").has("revival_kind")
|
||||
&& event.getAsJsonObject("data").get("revival_kind").getAsString().equals("admin_tail")).toList();
|
||||
}
|
||||
private List<JsonObject> ownConfig() throws Exception {
|
||||
return events().stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event -> event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString()
|
||||
.equals("admin-revive-selection-timeout-seconds")).toList();
|
||||
}
|
||||
private List<JsonObject> events() throws Exception { return events(data); }
|
||||
private List<JsonObject> events(Path root) throws Exception {
|
||||
var result = new ArrayList<JsonObject>();
|
||||
if (!Files.isDirectory(root.resolve("events"))) return result;
|
||||
try (var days = Files.list(root.resolve("events"))) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}"))
|
||||
.toList()) {
|
||||
try (var segments = Files.list(day)) {
|
||||
for (var file : segments.filter(path -> path.getFileName().toString().startsWith("events-")
|
||||
&& path.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
for (var line : Files.readAllLines(file)) {
|
||||
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
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");
|
||||
var deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
|
||||
while (System.nanoTime() < deadline) {
|
||||
if (sessions.active(actor.core.id).isEmpty() && !transitioning.containsKey(actor.core.id)
|
||||
&& restoredExitProof(fixture, actor)) return;
|
||||
// The receipt worker can advance independently of queued main-thread callbacks.
|
||||
var task = fixture.tasks.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
if (task != null) task.run();
|
||||
}
|
||||
fail("Session exit did not clear owned native marker and acknowledged receipt before restart; "
|
||||
+ "active=" + sessions.active(actor.core.id) + ", transitioning=" + transitioning.containsKey(actor.core.id)
|
||||
+ ", marker=" + actor.core.marker() + ", receipt_proof=" + restoredExitProof(fixture, actor));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -31,26 +31,83 @@ final class FishingQuestNativeTest {
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);when(actor.world.getName()).thenReturn("Fishing Realm");f.activate(actor);doReturn(null).when(actor.player).getFishHook();
|
||||
assertEquals(List.of("fishing"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","f"}));
|
||||
assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing",""}).contains("--name"));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","\"Moonlit Pond\"","--item","\"Silver Scale\"","--required","1","--chance","100","--xp","10","--coins","4","--radius","16","--mode","individual-only"});drain(f);
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","\"Moonlit Pond\"","--item","\"Silver Scale\"","--required","1","--chance","100","--xp","10","--coins","4","--radius","16","--mode","individual-only"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Placed fishing site")&&message.contains("Moonlit Pond")));
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var site=adventures.snapshot().sites().getFirst();assertEquals(AdventureQuestState.SiteType.FISHING,site.type());assertEquals("Moonlit Pond",site.name());assertEquals("Silver Scale",site.fishing().itemName());assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,site.participationMode());
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString()});assertTrue(actor.messages.getLast().contains("Silver Scale"));assertEquals(List.of(site.id().toString()),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","fishing",""}));
|
||||
int before=adventures.snapshot().sites().size();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","Broken","--item","X","--required","0","--chance","101","--xp","1","--coins","1"});assertEquals(before,adventures.snapshot().sites().size());
|
||||
var oldCatalog=adventures.snapshot().catalog();f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});await(f,()->adventures.snapshot().reset()==null&&adventures.snapshot().catalog()!=null&&(oldCatalog==null||!adventures.snapshot().catalog().revision().equals(oldCatalog.revision())));var catalog=adventures.snapshot().catalog();assertTrue(catalog.sites().contains(site.id()));
|
||||
var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.open(actor.player);drain(f);var offerMenu=actor.inventories.getLast();var offer=offer(offerMenu,"Moonlit Pond");var lore=plain(offer);assertTrue(lore.stream().anyMatch(line->line.contains("Mode: individual-only")));assertTrue(lore.stream().anyMatch(line->line.contains("Silver Scale × 1")));assertTrue(lore.stream().anyMatch(line->line.contains("Catch chance: 100%")));assertTrue(lore.stream().anyMatch(line->line.contains("10 XP, 4 coins")));click(controller,actor,offerMenu,offer);drain(f);var quest=adventures.active().getFirst();assertNotNull(quest.fishing());UUID stableItem=quest.fishing().itemId();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString(),"--item","\"Golden Scale\"","--required","2","--chance","0","--xp","20","--coins","8","--radius","4"});drain(f);var updatedSite=adventures.findSite(site.id()).orElseThrow();assertEquals(stableItem,updatedSite.fishing().itemId());assertEquals("Golden Scale",updatedSite.fishing().itemName());assertEquals("Silver Scale",quest.fishing().itemName(),"accepted terms remain immutable");assertEquals(16,quest.fishing().radius());actor.core.location.setX(10);
|
||||
var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.open(actor.player);drain(f);var offerMenu=actor.inventories.getLast();var offer=offer(offerMenu,"Moonlit Pond");var lore=plain(offer);assertTrue(lore.stream().anyMatch(line->line.contains("Mode: individual-only")));assertTrue(lore.stream().anyMatch(line->line.contains("Silver Scale × 1")));assertTrue(lore.stream().anyMatch(line->line.contains("Catch chance: 100%")));assertTrue(lore.stream().anyMatch(line->line.contains("10 XP, 4 coins")));click(controller,actor,offerMenu,offer);drain(f);var quest=adventures.active().getFirst();assertNotNull(quest.fishing());UUID stableItem=quest.fishing().itemId();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString(),"--item","\"Golden Scale\"","--required","2","--chance","0","--xp","20","--coins","8","--radius","4"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated fishing site")&&message.contains(site.id().toString())));var updatedSite=adventures.findSite(site.id()).orElseThrow();assertEquals(stableItem,updatedSite.fishing().itemId());assertEquals("Golden Scale",updatedSite.fishing().itemName());assertEquals("Silver Scale",quest.fishing().itemName(),"accepted terms remain immutable");assertEquals(16,quest.fishing().radius());actor.core.location.setX(10);
|
||||
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()));await(f,()->actor.messages.stream().anyMatch(message->message.contains("no quest item")));assertTrue(actor.messages.stream().anyMatch(message->message.contains("no quest item")));
|
||||
var next=mock(FishHook.class);when(next.getUniqueId()).thenReturn(UUID.randomUUID());when(next.getLocation()).thenReturn(actor.core.location.clone());
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,next,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,caught,first,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH));
|
||||
verify(next,never()).remove();
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,next,EquipmentSlot.HAND,PlayerFishEvent.State.LURED));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,next,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,caught,next,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH));
|
||||
verify(next).remove();await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(BigInteger.valueOf(4)));
|
||||
assertEquals(BigInteger.ZERO,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nonleaderMemberSuppliesWholeRequirementAndOfflinePartySharePersists() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
|
||||
@@ -104,6 +104,228 @@ class FrostShardNativeTest {
|
||||
assertEquals(1, damages.size());
|
||||
verify(projectile, atLeastOnce()).remove();
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
|
||||
for (var suffix : java.util.List.of("ability.learned", "ability.assigned", "ability.cast",
|
||||
"ability.resolved", "combat.damage", "effect.applied", "effect.ended"))
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)).count(), suffix);
|
||||
var castEvent = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("12", castEvent.get("captured_int").getAsString());
|
||||
assertEquals("3.0", castEvent.getAsJsonObject("captured_settings").get("projectile-speed").getAsString());
|
||||
var damage = own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(castEvent.get("action_id").getAsString(), damage.get("action_id").getAsString());
|
||||
assertEquals(castEvent.get("execution_id").getAsString(), damage.get("execution_id").getAsString());
|
||||
var resolution = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(resolution.get("projectile_id").getAsString(), damage.get("projectile_id").getAsString());
|
||||
assertTrue(damage.getAsJsonObject("impact_location").has("world_id"));
|
||||
assertEquals(5, damage.get("requested_amount").getAsDouble());
|
||||
assertEquals(5, damage.get("actual_amount").getAsDouble());
|
||||
assertTrue(damage.getAsJsonObject("target_location").has("world_id"));
|
||||
assertTrue(damage.has("direct_entity_id"));
|
||||
}
|
||||
|
||||
@Test void staleFlightIsInterruptedRatherThanFalselyReportedAsAnOrdinaryMiss() throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var frost = new games.dmg.fantasystick.ability.FrostShard(
|
||||
games.dmg.fantasystick.ability.FrostShard.Settings.defaults(),
|
||||
mock(games.dmg.fantasystick.presentation.ParticleDelivery.class));
|
||||
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
|
||||
when(execution.stillCurrent()).thenReturn(true, false);
|
||||
when(execution.definition()).thenReturn(frost.definition());
|
||||
when(execution.input()).thenReturn(input);
|
||||
when(execution.character()).thenReturn(actor.core.record);
|
||||
when(input.player()).thenReturn(actor.player);
|
||||
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
|
||||
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
|
||||
doReturn(eye).when(actor.player).getEyeLocation();
|
||||
var arrow = projectile(actor, new org.bukkit.Location(actor.world, 2, 65.6, 0));
|
||||
doReturn(arrow).when(actor.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
|
||||
frost.activate(execution);
|
||||
frost.hit(new org.bukkit.event.entity.ProjectileHitEvent(arrow));
|
||||
verify(execution).resolve(eq("interrupted"), anyMap());
|
||||
frost.close();
|
||||
}
|
||||
|
||||
@Test void pendingCastUsesItsCapturedDefinitionEvenIfConfigurationChangesBeforeLaunch() throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var particles = mock(games.dmg.fantasystick.presentation.ParticleDelivery.class);
|
||||
var frost = new games.dmg.fantasystick.ability.FrostShard(
|
||||
games.dmg.fantasystick.ability.FrostShard.Settings.defaults(), particles);
|
||||
var captured = frost.definition();
|
||||
frost.configure(new games.dmg.fantasystick.ability.FrostShard.Settings(BigInteger.valueOf(9),
|
||||
java.time.Duration.ofSeconds(5), 32, java.time.Duration.ofSeconds(5), 4, 0,
|
||||
java.time.Duration.ofSeconds(2)));
|
||||
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
|
||||
when(execution.stillCurrent()).thenReturn(true);
|
||||
when(execution.definition()).thenReturn(captured);
|
||||
when(execution.input()).thenReturn(input);
|
||||
when(execution.character()).thenReturn(actor.core.record);
|
||||
when(input.player()).thenReturn(actor.player);
|
||||
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
|
||||
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
|
||||
doReturn(eye).when(actor.player).getEyeLocation();
|
||||
var arrow = projectile(actor, new org.bukkit.Location(actor.world, 2, 65.6, 0));
|
||||
doReturn(arrow).when(actor.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(execution.eligible(any(), eq(1))).thenReturn(java.util.List.of(target));
|
||||
frost.activate(execution);
|
||||
var velocity = org.mockito.ArgumentCaptor.forClass(org.bukkit.util.Vector.class);
|
||||
verify(actor.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), velocity.capture());
|
||||
assertEquals(3.0, velocity.getValue().length(), 0.0001, "Pending cast retains the captured default speed");
|
||||
frost.hit(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
|
||||
var amount = org.mockito.ArgumentCaptor.forClass(double.class);
|
||||
verify(execution).damage(eq(target), amount.capture(), any(org.bukkit.damage.DamageSource.class));
|
||||
assertEquals(games.dmg.fantasystick.ability.FrostShard.damage(actor.core.record.stats()
|
||||
.get(games.dmg.fantasystick.progression.Stat.INT)).doubleValue(), amount.getValue());
|
||||
frost.close();
|
||||
}
|
||||
|
||||
@Test void registeredFrostShardConfigReadAndAcknowledgedSaveReachJsonlWithoutFailedWrite() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "frost-shard-base-damage"});
|
||||
assertTrue(admin.messages.getLast().contains("frost-shard-base-damage=4"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "frost-shard-base-damage", "9"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("9"), registry.find("frost_shard").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "frost-shard-base-damage", "11"});
|
||||
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed); failed.run();
|
||||
}
|
||||
assertEquals(new java.math.BigDecimal("9"), registry.find("frost_shard").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
admin.allowed = false;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "frost-shard-base-damage"});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")
|
||||
&& (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
|
||||
assertEquals(2, config.size(), "Denied and failed attempts cannot fabricate acknowledged changes");
|
||||
assertEquals("4", config.getLast().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("9", config.getLast().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void acceptedSlowExportsItsOwnedApplicationAndTheActuallyRestoredPriorLayer() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
prepareLaunches(f, a, arrow);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
var prior = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS, 100, 1);
|
||||
var current = new java.util.concurrent.atomic.AtomicReference<>(prior);
|
||||
when(target.getPotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS)).thenAnswer(ignored -> current.get());
|
||||
doAnswer(call -> { current.set(call.getArgument(0)); return true; }).when(target).addPotionEffect(any());
|
||||
doAnswer(call -> { current.set(null); return null; }).when(target).removePotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS);
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
|
||||
(Double) call.getArgument(0))); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
|
||||
assertNotNull(current.get()); assertEquals(40, current.get().getDuration());
|
||||
f.delayed.getLast().run();
|
||||
assertNotNull(current.get()); assertEquals(1, current.get().getAmplifier());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var effects = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")
|
||||
&& (event.get("type").getAsString().endsWith("effect.applied")
|
||||
|| event.get("type").getAsString().endsWith("effect.ended"))).toList();
|
||||
assertEquals(2, effects.size());
|
||||
var applied = effects.getFirst().getAsJsonObject("data");
|
||||
var ended = effects.getLast().getAsJsonObject("data");
|
||||
assertEquals(applied.get("application_id").getAsString(), ended.get("application_id").getAsString());
|
||||
assertEquals("replaced", applied.get("prior_layer").getAsString());
|
||||
assertEquals(1, applied.get("prior_amplifier").getAsInt());
|
||||
assertEquals("expired", ended.get("reason").getAsString());
|
||||
assertEquals("prior", ended.get("restored_layer").getAsString());
|
||||
}
|
||||
|
||||
@Test void newerNativeSlowLayerSurvivesOwnedCleanupAndIsNotReportedAsRestoredPrior() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0)); prepareLaunches(f, a, arrow);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
var current = new java.util.concurrent.atomic.AtomicReference<org.bukkit.potion.PotionEffect>();
|
||||
when(target.getPotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS)).thenAnswer(ignored -> current.get());
|
||||
doAnswer(call -> { current.set(call.getArgument(0)); return true; }).when(target).addPotionEffect(any());
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
|
||||
(Double) call.getArgument(0))); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
|
||||
var newer = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS, 200, 3);
|
||||
current.set(newer);
|
||||
f.delayed.getLast().run();
|
||||
assertSame(newer, current.get());
|
||||
verify(target, never()).removePotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var ended = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("effect.ended")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("superseded", ended.get("reason").getAsString());
|
||||
assertEquals("preserved_newer", ended.get("restored_layer").getAsString());
|
||||
}
|
||||
|
||||
@Test void naturallyExpiredOwnedSlowDoesNotClaimSupersessionOrInventRestoration() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0)); prepareLaunches(f, a, arrow);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
when(target.addPotionEffect(any())).thenReturn(true);
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
|
||||
(Double) call.getArgument(0))); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
|
||||
f.delayed.getLast().run();
|
||||
verify(target, never()).removePotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var ended = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("effect.ended")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("expired", ended.get("reason").getAsString());
|
||||
assertEquals("none", ended.get("restored_layer").getAsString());
|
||||
}
|
||||
|
||||
@Test void partyMixedModeAndCancelledDamageApplyNeitherDamageNorSlow() throws Exception {
|
||||
@@ -138,6 +360,77 @@ class FrostShardNativeTest {
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(third, target));
|
||||
verify(target, never()).addPotionEffect(any());
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
|
||||
assertEquals(3, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
|| event.get("type").getAsString().endsWith("effect.applied")).count());
|
||||
assertEquals(3, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
|
||||
}
|
||||
|
||||
@Test void acceptedFantasyPlayerFreezeHasOneOwnerNotAnIncomingNativeDuplicate() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
b.core.location.setWorld(a.world); b.core.location.setX(2); b.core.location.setY(65.6);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b);
|
||||
doReturn(org.bukkit.entity.EntityType.PLAYER).when(b.player).getType();
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageEvent(b.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FALL, 1));
|
||||
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
when(arrow.getType()).thenReturn(org.bukkit.entity.EntityType.ARROW);
|
||||
prepareLaunches(f, a, arrow);
|
||||
doReturn(true).when(b.player).addPotionEffect(any());
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, b.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
|
||||
(Double) call.getArgument(0));
|
||||
f.dispatcher.accept(hit); assertFalse(hit.isCancelled()); return null;
|
||||
}).when(b.player).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, b.player));
|
||||
verify(b.player, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
f.plugin.setEnabled(false);
|
||||
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
&& event.getAsJsonObject("data").has("target_player_id")
|
||||
&& event.getAsJsonObject("data").get("target_player_id").getAsString().equals(b.core.id.toString())).toList();
|
||||
assertEquals(2, damage.size(), "One unrelated fall and one Freeze; no duplicate incoming Freeze");
|
||||
assertEquals(1, damage.stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void zeroFinalNativeDamageIsNotAnAcceptedHitOrAFalseCancellation() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0)); prepareLaunches(f, a, arrow);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, 0);
|
||||
f.dispatcher.accept(hit);
|
||||
assertFalse(hit.isCancelled(), "Zero-damage native event was not itself cancelled");
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
|
||||
verify(target, never()).addPotionEffect(any());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("miss")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
|| event.get("type").getAsString().endsWith("effect.applied")).count());
|
||||
}
|
||||
|
||||
@Test void rangeLifetimeAndSessionEndRemoveOwnedProjectilesWithoutLateDamage() throws Exception {
|
||||
@@ -174,6 +467,14 @@ class FrostShardNativeTest {
|
||||
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine")).sweep();
|
||||
verify(exiting, atLeastOnce()).remove();
|
||||
}
|
||||
var outcomes = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard"))
|
||||
.map(event -> event.getAsJsonObject("data").get("outcome").getAsString()).toList();
|
||||
assertEquals(4, outcomes.size(), outcomes.toString());
|
||||
assertEquals(2, outcomes.stream().filter("miss"::equals).count(),
|
||||
"The outside Arrow and non-accepted boundary damage must not claim hits: " + outcomes);
|
||||
assertTrue(outcomes.containsAll(java.util.List.of("expired", "interrupted")), outcomes.toString());
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOwnedProjectile() throws Exception {
|
||||
@@ -212,6 +513,73 @@ class FrostShardNativeTest {
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("frost_shard"));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
|
||||
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
|
||||
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.unlearned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
|| event.get("type").getAsString().endsWith("effect.applied")).count());
|
||||
}
|
||||
|
||||
@Test void restartedDifferentHolderCannotRelaunchTheSameCommittedFrostDeadline() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 60\n");
|
||||
org.bukkit.inventory.ItemStack sheet;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0)); prepareLaunches(f, a, arrow);
|
||||
cast(f, a);
|
||||
verify(a.player, times(1)).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
|
||||
sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b);
|
||||
cast(f, b);
|
||||
verify(b.player, never()).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
|| event.get("type").getAsString().endsWith("effect.applied")).count());
|
||||
}
|
||||
|
||||
@Test void refusedCooldownWriteExportsFailureWithoutProjectileOrEffect() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
storage.close();
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player,
|
||||
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
|
||||
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
for (int tries = 0; tries < 4 && a.messages.stream().noneMatch(message -> message.contains("could not be saved")); tries++) {
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(response); response.run();
|
||||
}
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
|
||||
verify(a.player, never()).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("failed")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
|
||||
|| event.get("type").getAsString().endsWith("combat.damage")
|
||||
|| event.get("type").getAsString().endsWith("effect.applied")).count());
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceLaunchesNoProjectileOrSlow() {
|
||||
@@ -470,6 +838,20 @@ class FrostShardNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
|
||||
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var days = java.nio.file.Files.list(data.resolve("events"))) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
|
||||
try (var segments = java.nio.file.Files.list(day)) {
|
||||
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
|
||||
@@ -14,6 +14,160 @@ class GreatSwordNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredBookAndContainedStrikeExportOneCorrelatedAcceptedDamage() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withStrength(data, a, 12);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
var learn = abilityButton(a, "[Learn]", "great_sword");
|
||||
assertNotNull(learn);
|
||||
PartyBookNativeTest.click(f, a, learn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
when(a.player.getAttackCooldown()).thenReturn(1.0f);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
|
||||
(Double) call.getArgument(0));
|
||||
event.setDamage(6.5); f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var contained = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(contained); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertTrue(contained.isCancelled());
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f);
|
||||
verify(target, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
f.plugin.setEnabled(false);
|
||||
var owner = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
|
||||
assertEquals(1, owner.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
|
||||
assertEquals(1, owner.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
|
||||
var casts = owner.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).toList();
|
||||
assertEquals(1, casts.size());
|
||||
var cast = casts.getFirst().getAsJsonObject("data");
|
||||
assertEquals("12", cast.get("captured_str").getAsString());
|
||||
assertEquals("1.0", cast.get("captured_charge").getAsString());
|
||||
assertEquals("7", cast.getAsJsonObject("captured_settings").get("base-damage").getAsString());
|
||||
assertTrue(cast.getAsJsonObject("target_location").has("world_id"));
|
||||
assertTrue(cast.get("cooldown_until").getAsString().endsWith("Z"));
|
||||
var resolutions = owner.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")).toList();
|
||||
assertEquals(2, resolutions.size());
|
||||
assertEquals(1, resolutions.stream().filter(event -> event.getAsJsonObject("data")
|
||||
.get("outcome").getAsString().equals("hit")).count());
|
||||
assertEquals(1, resolutions.stream().filter(event -> event.getAsJsonObject("data")
|
||||
.get("outcome").getAsString().equals("refused")
|
||||
&& event.getAsJsonObject("data").get("reason").getAsString().equals("cooldown_active")).count());
|
||||
var damage = owner.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).toList();
|
||||
assertEquals(1, damage.size(), "Only the accepted custom hit is exported, never vanilla continuation");
|
||||
assertEquals(8, damage.getFirst().getAsJsonObject("data").get("requested_amount").getAsDouble());
|
||||
assertEquals(6.5, damage.getFirst().getAsJsonObject("data").get("actual_amount").getAsDouble());
|
||||
assertEquals(cast.get("action_id").getAsString(), damage.getFirst().getAsJsonObject("data").get("action_id").getAsString());
|
||||
assertEquals("8", damage.getFirst().getAsJsonObject("data").get("full_amount").getAsString());
|
||||
assertEquals("8.0", damage.getFirst().getAsJsonObject("data").get("scaled_requested_amount").getAsString());
|
||||
assertEquals(target.getUniqueId().toString(), damage.getFirst().getAsJsonObject("data").get("target_entity_id").getAsString());
|
||||
assertTrue(damage.getFirst().getAsJsonObject("data").getAsJsonObject("target_location").has("world_id"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredAdminGreatSwordConfigReadAndAcknowledgedUpdateExportOnlyCommittedChanges() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage"});
|
||||
assertTrue(admin.messages.getLast().contains("great-sword-base-damage=7"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage", "9"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(response, "A save must acknowledge before runtime publication"); response.run();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("9"), registry.find("great_sword").orElseThrow().parameters().get("base-damage"));
|
||||
assertTrue(java.nio.file.Files.readString(data.resolve("config.yml")).contains("great-sword-base-damage"));
|
||||
try (var lockFile = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = lockFile.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage", "11"});
|
||||
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(failed); failed.run();
|
||||
}
|
||||
assertEquals(new java.math.BigDecimal("9"), registry.find("great_sword").orElseThrow().parameters().get("base-damage"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage", "-1"});
|
||||
assertTrue(admin.messages.getLast().contains("invalid"));
|
||||
admin.allowed = false;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage"});
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage", "13"});
|
||||
assertEquals(java.util.List.of(), f.command.tabComplete(admin.port, "fstickadmin",
|
||||
new String[]{"config", "great-sword-base"}));
|
||||
admin.allowed = true;
|
||||
assertEquals(java.util.List.of("great-sword-base-damage"), f.command.tabComplete(admin.port, "fstickadmin",
|
||||
new String[]{"config", "great-sword-base"}));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().equals("great-sword-base-damage")
|
||||
&& (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
|
||||
assertEquals(2, config.size(), "Read and committed update only; no failed-write success");
|
||||
assertEquals("7", config.getLast().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("9", config.getLast().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void acknowledgedRecoverySettingAffectsOnlyFutureStrikesAndSurvivesRestart() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withGreatSword(data, a, 12);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
when(a.player.getAttackCooldown()).thenReturn(1.0f);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
|
||||
(Double) call.getArgument(0))); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var originalDeadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword");
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-recovery-seconds"});
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-recovery-seconds", "2"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(java.time.Duration.ofSeconds(2), registry.find("great_sword").orElseThrow().cooldown());
|
||||
assertEquals(originalDeadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(java.time.Duration.ofSeconds(2), registry.find("great_sword").orElseThrow().cooldown());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var changes = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")
|
||||
&& event.get("type").getAsString().endsWith("config.changed")).toList();
|
||||
assertEquals(1, changes.size());
|
||||
assertEquals("great-sword-recovery-seconds", changes.getFirst().getAsJsonObject("data").get("key").getAsString());
|
||||
assertEquals("0.75", changes.getFirst().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("2", changes.getFirst().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void bookAcquisitionAndFullyChargedEquippedStrikeReplaceVanillaDamage() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -128,6 +282,8 @@ class GreatSwordNativeTest {
|
||||
when(a.player.getAttackCooldown()).thenReturn(0.5f);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
|
||||
var damages = new java.util.ArrayList<Double>();
|
||||
doAnswer(call -> {
|
||||
double amount = call.getArgument(0); damages.add(amount);
|
||||
@@ -142,6 +298,48 @@ class GreatSwordNativeTest {
|
||||
assertTrue(attack.isCancelled());
|
||||
assertEquals(java.util.List.of(3.2), damages);
|
||||
}
|
||||
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
|
||||
assertEquals(1, damage.size());
|
||||
assertEquals(3.2, damage.getFirst().getAsJsonObject("data").get("requested_amount").getAsDouble());
|
||||
assertEquals("8", damage.getFirst().getAsJsonObject("data").get("full_amount").getAsString());
|
||||
assertEquals("0.5", damage.getFirst().getAsJsonObject("data").get("charge").getAsString());
|
||||
}
|
||||
|
||||
@Test void acceptedFantasyPlayerStrikeHasOneOwnerNotAnIncomingNativeDuplicate() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withGreatSword(data, a, 12);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
b.core.location.setWorld(a.world);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b);
|
||||
when(a.player.getAttackCooldown()).thenReturn(1.0f);
|
||||
doReturn(org.bukkit.entity.EntityType.PLAYER).when(a.player).getType();
|
||||
doReturn(org.bukkit.entity.EntityType.PLAYER).when(b.player).getType();
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageEvent(b.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FALL, 1));
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, b.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
|
||||
(Double) call.getArgument(0));
|
||||
f.dispatcher.accept(event);
|
||||
assertFalse(event.isCancelled()); return null;
|
||||
}).when(b.player).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
verify(b.player, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
f.plugin.setEnabled(false);
|
||||
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
&& event.getAsJsonObject("data").has("target_player_id")
|
||||
&& event.getAsJsonObject("data").get("target_player_id").getAsString().equals(b.core.id.toString())).toList();
|
||||
assertEquals(2, damage.size(), "One ordinary fall observation plus one strike, no second incoming strike");
|
||||
assertEquals(1, damage.stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void refusedPlayersAndCancelledNativeDamagePreserveProtectionWithoutFreeVanillaHits() throws Exception {
|
||||
@@ -157,6 +355,7 @@ class GreatSwordNativeTest {
|
||||
assertTrue(parties.accept(b.core.id,
|
||||
parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
|
||||
when(a.player.getAttackCooldown()).thenReturn(1.0f);
|
||||
doReturn(org.bukkit.entity.EntityType.PLAYER).when(b.player).getType();
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
|
||||
var partyAttack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true);
|
||||
@@ -166,6 +365,8 @@ class GreatSwordNativeTest {
|
||||
|
||||
var ordinary = mock(org.bukkit.entity.Player.class);
|
||||
when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
doReturn(org.bukkit.entity.EntityType.PLAYER).when(ordinary).getType();
|
||||
when(ordinary.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
|
||||
var ordinaryAttack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, ordinary, true);
|
||||
f.dispatcher.accept(ordinaryAttack); PartyBookNativeTest.drain(f);
|
||||
assertTrue(ordinaryAttack.isCancelled());
|
||||
@@ -196,6 +397,17 @@ class GreatSwordNativeTest {
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(BigInteger.ZERO, storage.find(a.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count(),
|
||||
"Refused friendly and ordinary targets spend no recovery");
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count(),
|
||||
"A cancelled hit never claims accepted custom damage");
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
|
||||
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("refused")
|
||||
&& event.getAsJsonObject("data").get("reason").getAsString().equals("target_ineligible")).count());
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndWholeCharacterTransferRetainTheOriginalRecovery() throws Exception {
|
||||
@@ -208,6 +420,8 @@ class GreatSwordNativeTest {
|
||||
when(a.player.getAttackCooldown()).thenReturn(1.0f);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
|
||||
var hits = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doAnswer(call -> {
|
||||
hits.incrementAndGet();
|
||||
@@ -257,6 +471,61 @@ class GreatSwordNativeTest {
|
||||
assertEquals(1, hits.get(), "Transfer cannot reset the character-bound recovery");
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
|
||||
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
|
||||
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.unlearned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count(),
|
||||
"Retry, relearn and transferred holder cannot claim another strike before the retained deadline");
|
||||
}
|
||||
|
||||
@Test void restartAndDifferentHolderCannotReapplyACompletedChargedStrike() throws Exception {
|
||||
org.bukkit.inventory.ItemStack sheet;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withGreatSword(data, a, 12);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
when(a.player.getAttackCooldown()).thenReturn(1.0f);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
|
||||
(Double) call.getArgument(0))); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
verify(target, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
b.core.inventory.setItemInOffHand(sheet);
|
||||
f.players.put(b.core.id, b.player); f.activate(b);
|
||||
when(b.player.getAttackCooldown()).thenReturn(1.0f);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(b.world, 4, 64, 0));
|
||||
b.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(b.player, target, true));
|
||||
PartyBookNativeTest.drain(f);
|
||||
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var records = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
|
||||
assertEquals(1, records.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(1, records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void fractionalRecoveryRefusesImmediatelyAndOneNanosecondBeforeButAcceptsExactBoundary() throws Exception {
|
||||
@@ -307,6 +576,37 @@ class GreatSwordNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void refusedCooldownSaveExportsFailedResolutionWithoutInventingDamage() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withGreatSword(data, a, 12);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
when(a.player.getAttackCooldown()).thenReturn(1.0f);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
storage.close();
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
for (int tries = 0; tries < 4 && a.messages.stream().noneMatch(message -> message.contains("could not be saved")); tries++) {
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(response); response.run();
|
||||
}
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
|
||||
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("failed")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
|
||||
|| event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceDealsNoDamageOrFreeEffect() {
|
||||
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
|
||||
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
@@ -449,6 +749,16 @@ class GreatSwordNativeTest {
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
|
||||
var events = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
|
||||
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(file))
|
||||
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private static void await(java.util.concurrent.atomic.AtomicInteger value, int expected) throws Exception {
|
||||
for (int attempt = 0; attempt < 500 && value.get() < expected; attempt++) Thread.sleep(10);
|
||||
assertEquals(expected, value.get());
|
||||
|
||||
@@ -28,6 +28,7 @@ class HexNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookCastActualPreventionAndQualifyingAllyDamagePersistTwoRewardShares() throws Exception {
|
||||
java.util.UUID actorId, characterId, sessionId, enemyId, protectedId, protectedCharacterId, protectedSessionId, worldId;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
@@ -39,11 +40,19 @@ class HexNativeTest {
|
||||
assertTrue(book.contains("Hex")); assertTrue(book.contains("CHR14")); assertTrue(book.contains("WIS12"));
|
||||
assertFalse(book.contains("Cost:")); assertTrue(book.contains("12")); assertTrue(book.contains("20%")); assertTrue(book.contains("4s"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertTrue(f.feedbackCommand.execute(a.player, "fantasyfeedback", new String[]{"off"})); f.tick();
|
||||
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class); var enemyId = java.util.UUID.randomUUID();
|
||||
actorId = a.core.id; characterId = a.core.record.id(); protectedId = b.core.id;
|
||||
protectedCharacterId = b.core.record.id();
|
||||
var activeSessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
sessionId = activeSessions.active(actorId).orElseThrow().session();
|
||||
protectedSessionId = activeSessions.active(protectedId).orElseThrow().session();
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class); enemyId = java.util.UUID.randomUUID();
|
||||
when(enemy.getUniqueId()).thenReturn(enemyId); when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
worldId = java.util.UUID.randomUUID(); when(a.world.getUID()).thenReturn(worldId);
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
doReturn(new Location(a.world, 0, 64, 1)).when(b.player).getLocation();
|
||||
when(enemy.getLocation()).thenReturn(new Location(a.world, 8, 64, 0)); doReturn(true).when(a.player).hasLineOfSight(enemy);
|
||||
doReturn(enemy).when(a.player).getTargetEntity(64);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
@@ -65,6 +74,75 @@ class HexNativeTest {
|
||||
assertEquals(BigInteger.valueOf(5), storage.find(a.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage.find(b.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
|
||||
assertEquals(1, count(own, "ability.learned")); assertEquals(1, count(own, "ability.assigned"));
|
||||
assertEquals(1, count(own, "ability.cast")); assertEquals(1, count(own, "effect.applied"));
|
||||
assertEquals(1, count(own, "combat.prevented")); assertEquals(1, count(own, "effect.ended"));
|
||||
var cast = record(own, "ability.cast"); var applied = record(own, "effect.applied");
|
||||
var prevented = record(own, "combat.prevented"); var ended = record(own, "effect.ended");
|
||||
assertEquals(2, cast.get("slot").getAsInt()); assertTrue(cast.has("cooldown_until"));
|
||||
assertEquals(actorId.toString(), prevented.get("player_id").getAsString());
|
||||
assertEquals(characterId.toString(), prevented.get("character_id").getAsString());
|
||||
assertEquals(sessionId.toString(), prevented.get("session_id").getAsString());
|
||||
assertEquals(enemyId.toString(), prevented.get("debuffed_attacker_id").getAsString());
|
||||
assertEquals(protectedId.toString(), prevented.get("protected_target_id").getAsString());
|
||||
assertEquals(protectedCharacterId.toString(), prevented.get("protected_character_id").getAsString());
|
||||
assertEquals(protectedSessionId.toString(), prevented.get("protected_session_id").getAsString());
|
||||
assertEquals(worldId.toString(), prevented.getAsJsonObject("attacker_location").get("world_id").getAsString());
|
||||
assertEquals(8, prevented.getAsJsonObject("attacker_location").get("x").getAsDouble(), 1.0e-9);
|
||||
assertEquals(worldId.toString(), prevented.getAsJsonObject("protected_location").get("world_id").getAsString());
|
||||
assertEquals(1, prevented.getAsJsonObject("protected_location").get("z").getAsDouble(), 1.0e-9);
|
||||
assertEquals(10, prevented.get("incoming_amount").getAsDouble(), 1.0e-9);
|
||||
assertEquals(8, prevented.get("remaining_amount").getAsDouble(), 1.0e-9);
|
||||
assertEquals(2, prevented.get("actual_amount").getAsDouble(), 1.0e-9);
|
||||
assertEquals(cast.get("action_id").getAsString(), prevented.get("action_id").getAsString());
|
||||
assertEquals(applied.get("application_id").getAsString(), ended.get("application_id").getAsString());
|
||||
assertEquals(applied.get("application_id").getAsString(), prevented.get("effect_application_id").getAsString());
|
||||
assertEquals("interrupted", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
|
||||
@Test void rejectedEventWriterCannotAlterHexPreventionOrPersistedPartyReward() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withHex(data, a, true);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b);
|
||||
var parties = PartyLifecycleNativeTest.parties(f); var party = parties.create(a.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
doReturn(new Location(a.world, 1, 64, 0)).when(b.player).getLocation();
|
||||
when(enemy.getLocation()).thenReturn(new Location(a.world, 8, 64, 0));
|
||||
doReturn(true).when(a.player).hasLineOfSight(enemy);
|
||||
doReturn(enemy).when(a.player).getTargetEntity(64);
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
var allySource = DamageSource.builder(DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(b.player).withDirectEntity(b.player).build();
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(b.player, enemy,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, allySource, 3));
|
||||
var enemySource = DamageSource.builder(DamageType.MOB_ATTACK)
|
||||
.withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit = new EntityDamageByEntityEvent(enemy, b.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, enemySource, 10);
|
||||
f.dispatcher.accept(hit); assertEquals(8, hit.getDamage(), 1.0e-9);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy, allySource,
|
||||
new java.util.ArrayList<>(), 0));
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(BigInteger.valueOf(5), storage.find(a.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage.find(b.core.record.id()).get().orElseThrow().experience());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertEquals(0, events(data).stream().filter(event -> event.get("type").getAsString()
|
||||
.endsWith("combat.prevented")).count());
|
||||
}
|
||||
|
||||
@Test void trainingDummyConsumesNoOwnedHexEffect() {
|
||||
@@ -87,10 +165,14 @@ class HexNativeTest {
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);
|
||||
use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
assertEquals(2,f.delayed.size());f.delayed.getFirst().run();
|
||||
var hit=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
|
||||
var recipient=mock(org.bukkit.entity.Zombie.class);when(recipient.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(recipient.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(recipient.getLocation()).thenReturn(new Location(a.world,3,64,0));
|
||||
var hit=new EntityDamageByEntityEvent(enemy,recipient,EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
|
||||
f.dispatcher.accept(hit);assertEquals(8,hit.getDamage(),1.0e-9);f.delayed.getLast().run();
|
||||
var expired=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
|
||||
f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1.0e-9);
|
||||
@@ -98,6 +180,15 @@ class HexNativeTest {
|
||||
var exited=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
|
||||
f.dispatcher.accept(exited);assertEquals(10,exited.getDamage(),1.0e-9);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
|
||||
assertEquals(3, count(own, "effect.applied"));
|
||||
assertEquals(3, count(own, "effect.ended"));
|
||||
assertEquals(1, count(own, "combat.prevented"));
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("replaced")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("expired")).count());
|
||||
}
|
||||
|
||||
@Test void latestReplacementOwnsEffectivePreventionSupport() throws Exception {
|
||||
@@ -109,6 +200,41 @@ class HexNativeTest {
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var ax=storage.find(a.core.record.id()).get().orElseThrow().experience();var cx=storage.find(c.core.record.id()).get().orElseThrow().experience();var bx=storage.find(b.core.record.id()).get().orElseThrow().experience();assertEquals(BigInteger.TEN,ax.add(cx).add(bx));assertTrue(List.of(ax,cx,bx).stream().allMatch(value->value.equals(BigInteger.valueOf(3))||value.equals(BigInteger.valueOf(4))));
|
||||
}}
|
||||
|
||||
@Test void unusedOrLaterOverriddenDamageCannotClaimHexPrevention() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withHex(data, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(enemy.getLocation()).thenReturn(new Location(a.world, 8, 64, 0));
|
||||
doReturn(true).when(a.player).hasLineOfSight(enemy);
|
||||
doReturn(enemy).when(a.player).getTargetEntity(64);
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var recipient = mock(org.bukkit.entity.Zombie.class);
|
||||
when(recipient.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(recipient.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(recipient.getLocation()).thenReturn(new Location(a.world, 3, 64, 0));
|
||||
class Override implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void restore(EntityDamageByEntityEvent event) { event.setDamage(10); }
|
||||
}
|
||||
f.dispatcher.register(new Override(), f.plugin);
|
||||
var source = DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit = new EntityDamageByEntityEvent(enemy, recipient, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, 10);
|
||||
f.dispatcher.accept(hit); assertEquals(10, hit.getDamage(), 1.0e-9);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
|
||||
assertEquals(1, count(own, "effect.applied"));
|
||||
assertEquals(0, count(own, "combat.prevented"));
|
||||
}
|
||||
|
||||
@Test void unusedProtectionCannotManufactureSupportShare() throws Exception {
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);
|
||||
@@ -124,6 +250,55 @@ class HexNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedPreventionFeedbackCannotInterruptNativeDamageOrOwnerAudit() {
|
||||
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
var parties = mock(games.dmg.fantasystick.party.PartyRoster.class);
|
||||
var mitigation = new games.dmg.fantasystick.ability.OutgoingDamageMitigation(sessions, parties, ignored -> {});
|
||||
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
when(execution.stillCurrent()).thenReturn(true);
|
||||
doThrow(new IllegalStateException("simulated feedback failure")).when(execution).report(any(), any(), any());
|
||||
var attacker = mock(org.bukkit.entity.Zombie.class);
|
||||
when(attacker.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
var target = mock(org.bukkit.entity.Zombie.class);
|
||||
var accepted = new java.util.ArrayList<games.dmg.fantasystick.ability.OutgoingDamageMitigation.Accepted>();
|
||||
mitigation.grantObserved(execution, attacker, new java.math.BigDecimal("20"), accepted::add);
|
||||
var source = DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(attacker).withDirectEntity(attacker).build();
|
||||
var hit = new EntityDamageByEntityEvent(attacker, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 1.0e-9);
|
||||
assertDoesNotThrow(() -> mitigation.accepted(hit));
|
||||
assertEquals(1, accepted.size()); assertEquals(2, accepted.getFirst().prevented(), 1.0e-9);
|
||||
mitigation.close();
|
||||
}
|
||||
|
||||
@Test void failedCooldownSaveCannotPublishHexEffectOrPrevention() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withHex(data, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(enemy.getLocation()).thenReturn(new Location(a.world, 8, 64, 0));
|
||||
doReturn(true).when(a.player).hasLineOfSight(enemy);
|
||||
doReturn(enemy).when(a.player).getTargetEntity(64);
|
||||
((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters")).close();
|
||||
use(f, a); var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(response); response.run();
|
||||
var hit = new EntityDamageByEntityEvent(enemy, mock(org.bukkit.entity.Zombie.class),
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(DamageSource.class), 10);
|
||||
f.dispatcher.accept(hit); assertEquals(10, hit.getDamage(), 1.0e-9);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
|
||||
assertEquals(0, count(own, "ability.cast"));
|
||||
assertEquals(0, count(own, "effect.applied"));
|
||||
assertEquals(0, count(own, "combat.prevented"));
|
||||
assertEquals("failed", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceAppliesNoHex() {
|
||||
var storage=mock(CharacterStorage.class);var sessions=mock(games.dmg.fantasystick.session.EquipmentSessions.class);var parties=new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(),ignored->true);
|
||||
var mitigation=new games.dmg.fantasystick.ability.OutgoingDamageMitigation(sessions,parties,ignored->{});var hex=new games.dmg.fantasystick.ability.Hex(games.dmg.fantasystick.ability.Hex.Settings.defaults(),mitigation);var registry=new games.dmg.fantasystick.ability.AbilityRegistry();registry.register(hex.definition());
|
||||
@@ -139,11 +314,15 @@ class HexNativeTest {
|
||||
@Test void cancelledDamageCreatesNoReductionOrSupportButRetainsAcknowledgedCooldown() throws Exception {
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);
|
||||
f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
class Veto implements org.bukkit.event.Listener{@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.LOWEST)public void damage(EntityDamageByEntityEvent event){if(event.getDamager()==enemy)event.setCancelled(true);}}
|
||||
f.dispatcher.register(new Veto(),f.plugin);var source=DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new EntityDamageByEntityEvent(enemy,b.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertTrue(hit.isCancelled());assertEquals(10,hit.getDamage(),1.0e-9);
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
class Veto implements org.bukkit.event.Listener{@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGHEST)public void damage(EntityDamageByEntityEvent event){if(event.getDamager()==enemy)event.setCancelled(true);}}
|
||||
f.dispatcher.register(new Veto(),f.plugin);var source=DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new EntityDamageByEntityEvent(enemy,b.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertTrue(hit.isCancelled());assertEquals(8,hit.getDamage(),1.0e-9);
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("hex"));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
|
||||
assertEquals(1, count(own, "effect.applied"));
|
||||
assertEquals(0, count(own, "combat.prevented"), "Cancellation after the outgoing stage cannot claim real prevention");
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOwnedPenalty() throws Exception {
|
||||
@@ -176,6 +355,38 @@ class HexNativeTest {
|
||||
for(String invalid:List.of("hex-cooldown-seconds: -1\n","hex-range-blocks: 0\n","hex-duration-seconds: 0\n","hex-damage-reduction-percent: 101\n")){var dir=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),invalid);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}
|
||||
}
|
||||
|
||||
@Test void authorizedHexConfigurationReadAndAcknowledgedChangeReachJsonlOnlyAfterSave() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "hex-damage-reduction-percent"});
|
||||
assertTrue(admin.messages.getLast().contains("hex-damage-reduction-percent=20"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "hex-damage-reduction-percent", "30"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("30"), registry.find("hex").orElseThrow()
|
||||
.parameters().get("damage-reduction-percent"));
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "hex-damage-reduction-percent", "40"});
|
||||
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed); failed.run();
|
||||
}
|
||||
admin.allowed = false;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "hex-damage-reduction-percent"});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")
|
||||
&& (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
|
||||
assertEquals(2, config.size());
|
||||
assertEquals("20", config.getLast().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("30", config.getLast().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void partyAndMixedModeAimedPlayersRefuseWithoutCooldown() throws Exception {
|
||||
for(int route=0;route<2;route++){var dir=data.resolve("player-refused-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withHex(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;
|
||||
if(route==0){var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));}else{target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}
|
||||
@@ -184,8 +395,35 @@ class HexNativeTest {
|
||||
|
||||
@Test void firstAimedOutOfRangeOrObstructedTargetRefusesWithoutFallbackOrCooldown() throws Exception {
|
||||
for(int route=0;route<2;route++){var dir=data.resolve("refused-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withHex(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,route==0?13:8,64,0));doReturn(route==0).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("hex"));}}
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,route==0?13:8,64,0));doReturn(route==0).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("hex"));}
|
||||
var own = events(dir).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
|
||||
assertEquals(0, count(own, "ability.cast"));
|
||||
assertEquals(0, count(own, "effect.applied"));
|
||||
assertEquals("refused", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
|
||||
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var days = java.nio.file.Files.list(data.resolve("events"))) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
|
||||
try (var segments = java.nio.file.Files.list(day)) {
|
||||
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
private static long count(java.util.List<com.google.gson.JsonObject> own, String suffix) {
|
||||
return own.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)).count();
|
||||
}
|
||||
private static com.google.gson.JsonObject record(java.util.List<com.google.gson.JsonObject> own, String suffix) {
|
||||
return own.stream().filter(event -> event.get("type").getAsString().endsWith(suffix))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,abilityButton(a,"[Learn]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
|
||||
|
||||
@@ -2,24 +2,223 @@ package games.dmg.fantasystick;
|
||||
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.Mockito.*;
|
||||
import games.dmg.fantasystick.persistence.*;import games.dmg.fantasystick.progression.Stat;import java.math.BigInteger;import java.nio.file.Path;import java.util.*;import net.minecraft.network.chat.ClickEvent;import org.bukkit.event.player.PlayerInteractEvent;import org.bukkit.inventory.EquipmentSlot;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
|
||||
class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void registeredBookSelfCastExpiryReachesExportedJsonlWithoutInventingPrevention()throws Exception{
|
||||
UUID playerId,characterId,sessionId;
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,actor,false);
|
||||
f.players.put(actor.core.id,actor.player);f.activate(actor);playerId=actor.core.id;characterId=actor.core.record.id();
|
||||
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");
|
||||
sessionId=sessions.active(playerId).orElseThrow().session();
|
||||
learnAndAssign(f,actor);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);
|
||||
doAnswer(call->absorption.get()).when(actor.player).getAbsorptionAmount();
|
||||
doAnswer(call->{absorption.set(call.getArgument(0));return null;}).when(actor.player).setAbsorptionAmount(anyDouble());
|
||||
doReturn(true).when(actor.player).isSneaking();use(f,actor);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
assertEquals(7,absorption.get(),1e-9);f.delayed.getLast().run();assertEquals(2,absorption.get(),1e-9);
|
||||
}
|
||||
var own=inspireEvents(data);assertEquals(1,count(own,"ability.learned"));assertEquals(1,count(own,"ability.assigned"));
|
||||
assertEquals(1,count(own,"ability.cast"));assertEquals(1,count(own,"effect.applied"));
|
||||
assertEquals(1,count(own,"effect.ended"));assertEquals(0,count(own,"combat.prevented"));
|
||||
var cast=record(own,"ability.cast");var applied=record(own,"effect.applied");var ended=record(own,"effect.ended");
|
||||
assertEquals(playerId.toString(),cast.get("player_id").getAsString());
|
||||
assertEquals(characterId.toString(),cast.get("character_id").getAsString());
|
||||
assertEquals(sessionId.toString(),cast.get("session_id").getAsString());
|
||||
assertTrue(cast.has("cooldown_until"));assertEquals("5",applied.get("granted_amount").getAsString());
|
||||
assertEquals(applied.get("application_id").getAsString(),ended.get("application_id").getAsString());
|
||||
assertEquals("5",ended.get("remaining_amount").getAsString(),"unused native protection expires without prevention");
|
||||
assertEquals("expired",ended.get("reason").getAsString());
|
||||
assertEquals("expired",record(own,"ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
@Test void registeredAllyAbsorptionRecordsOnlyObservedConsumedHealth()throws Exception{
|
||||
UUID actorId,characterId,sessionId,targetId,targetCharacterId,targetSessionId,worldId;
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withInspire(data,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);
|
||||
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");
|
||||
actorId=a.core.id;characterId=a.core.record.id();sessionId=sessions.active(actorId).orElseThrow().session();
|
||||
targetId=b.core.id;targetCharacterId=b.core.record.id();targetSessionId=sessions.active(targetId).orElseThrow().session();
|
||||
worldId=UUID.randomUUID();when(a.world.getUID()).thenReturn(worldId);
|
||||
doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();
|
||||
doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();
|
||||
doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);
|
||||
var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);
|
||||
doAnswer(call->absorption.get()).when(b.player).getAbsorptionAmount();
|
||||
doAnswer(call->{absorption.set(call.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());
|
||||
assertTrue(f.feedbackCommand.execute(a.player,"fantasyfeedback",new String[]{"off"}));f.tick();
|
||||
use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,1));
|
||||
var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
|
||||
.withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);cancelled.setCancelled(true);
|
||||
f.dispatcher.accept(cancelled);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
|
||||
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);
|
||||
f.dispatcher.accept(hit);absorption.set(2.0);PartyBookNativeTest.drain(f);
|
||||
assertEquals(2,absorption.get(),1e-9);
|
||||
}
|
||||
var own=inspireEvents(data);assertEquals(1,count(own,"ability.cast"));assertEquals(1,count(own,"effect.applied"));
|
||||
assertEquals(1,count(own,"combat.prevented"));var consumed=record(own,"combat.prevented");
|
||||
assertEquals(actorId.toString(),consumed.get("player_id").getAsString());
|
||||
assertEquals(characterId.toString(),consumed.get("character_id").getAsString());
|
||||
assertEquals(sessionId.toString(),consumed.get("session_id").getAsString());
|
||||
assertEquals(targetId.toString(),consumed.get("protected_target_id").getAsString());
|
||||
assertEquals(targetCharacterId.toString(),consumed.get("protected_character_id").getAsString());
|
||||
assertEquals(targetSessionId.toString(),consumed.get("protected_session_id").getAsString());
|
||||
assertEquals(worldId.toString(),consumed.getAsJsonObject("protected_location").get("world_id").getAsString());
|
||||
assertEquals(8,consumed.getAsJsonObject("protected_location").get("x").getAsDouble(),1e-9);
|
||||
assertEquals(3,consumed.get("actual_amount").getAsDouble(),1e-9);
|
||||
assertEquals(record(own,"ability.cast").get("action_id").getAsString(),consumed.get("action_id").getAsString());
|
||||
assertEquals(record(own,"effect.applied").get("application_id").getAsString(),consumed.get("effect_application_id").getAsString());
|
||||
}
|
||||
@Test void unavailableAuditOnlyAttackerLocationCannotInterruptNativeAbsorption()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);
|
||||
f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);
|
||||
doAnswer(call->absorption.get()).when(a.player).getAbsorptionAmount();
|
||||
doAnswer(call->{absorption.set(call.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());
|
||||
doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getLocation()).thenThrow(new IllegalStateException("unavailable audit location"));
|
||||
var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
|
||||
.withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,a.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);
|
||||
var owner=(games.dmg.fantasystick.ability.TemporaryHealth)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"temporaryHealth");
|
||||
owner.damage(hit);absorption.set(2.0);PartyBookNativeTest.drain(f);assertEquals(2,absorption.get(),1e-9);
|
||||
}
|
||||
var own=inspireEvents(data);assertEquals(1,count(own,"combat.prevented"));
|
||||
assertFalse(record(own,"combat.prevented").has("attacker_location"));
|
||||
}
|
||||
@Test void rejectedNativeAbsorptionSetterCannotLeaveAnOrphanOwnedGrant()throws Exception{
|
||||
var attempts=new java.util.concurrent.atomic.AtomicInteger();
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);
|
||||
f.players.put(a.core.id,a.player);f.activate(a);
|
||||
doReturn(0.0).when(a.player).getAbsorptionAmount();
|
||||
doAnswer(call->{if(attempts.incrementAndGet()==1)throw new IllegalStateException("native refused");
|
||||
return null;}).when(a.player).setAbsorptionAmount(anyDouble());
|
||||
doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
assertEquals(1,attempts.get(),"native admission was attempted once");
|
||||
}
|
||||
assertEquals(1,attempts.get(),"an unadmitted effect must not be cleaned up as an owned native grant");
|
||||
var own=inspireEvents(data);assertEquals(1,count(own,"ability.cast"));
|
||||
assertEquals(0,count(own,"effect.applied"));assertEquals(0,count(own,"combat.prevented"));
|
||||
assertEquals("failed",record(own,"ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
@Test void duplicateNativeCallbackCannotDoubleClaimOneAbsorptionDrop()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);
|
||||
f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);
|
||||
doAnswer(call->absorption.get()).when(a.player).getAbsorptionAmount();
|
||||
doAnswer(call->{absorption.set(call.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());
|
||||
doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
assertEquals(5,absorption.get(),1e-9);
|
||||
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
|
||||
.withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,a.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);
|
||||
f.dispatcher.accept(hit);f.dispatcher.accept(hit);absorption.set(2.0);PartyBookNativeTest.drain(f);
|
||||
assertEquals(2,absorption.get(),1e-9);
|
||||
}
|
||||
var own=inspireEvents(data);assertEquals(1,count(own,"combat.prevented"));
|
||||
assertEquals(3,record(own,"combat.prevented").get("actual_amount").getAsDouble(),1e-9);
|
||||
}
|
||||
@Test void rejectedWriterCannotChangeNativeAbsorptionOrInventPrevention()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);
|
||||
f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);
|
||||
doAnswer(call->absorption.get()).when(a.player).getAbsorptionAmount();
|
||||
doAnswer(call->{absorption.set(call.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());
|
||||
doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();
|
||||
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
|
||||
.withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,a.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);
|
||||
f.dispatcher.accept(hit);absorption.set(2.0);PartyBookNativeTest.drain(f);
|
||||
assertEquals(2,absorption.get(),1e-9);f.delayed.getLast().run();assertEquals(0,absorption.get(),1e-9);
|
||||
}
|
||||
assertEquals(0,count(inspireEvents(data),"combat.prevented"));
|
||||
}
|
||||
@Test void acknowledgedInspireConfigurationReadAndChangeReachExportedJsonl()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var admin=new EquipmentAdminCommandNativeTest.Sender();admin.allowed=true;
|
||||
f.command.execute(admin.port,"fstickadmin",new String[]{"config","inspire-base-temporary-health"});
|
||||
assertTrue(admin.messages.getLast().contains("inspire-base-temporary-health=4"));
|
||||
f.command.execute(admin.port,"fstickadmin",new String[]{"config","inspire-base-temporary-health","9"});
|
||||
var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();
|
||||
var registry=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("9"),registry.find("inspire").orElseThrow().parameters().get("base-temporary-health"));
|
||||
try(var channel=java.nio.channels.FileChannel.open(data.resolve(".config.lock"),java.nio.file.StandardOpenOption.CREATE,java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock=channel.lock()){
|
||||
assertTrue(lock.isValid());
|
||||
f.command.execute(admin.port,"fstickadmin",new String[]{"config","inspire-base-temporary-health","10"});
|
||||
var failed=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(failed);failed.run();
|
||||
}
|
||||
admin.allowed=false;f.command.execute(admin.port,"fstickadmin",new String[]{"config","inspire-base-temporary-health"});
|
||||
}
|
||||
var own=inspireEvents(data);assertEquals(1,count(own,"config.read"));assertEquals(1,count(own,"config.changed"));
|
||||
var changed=record(own,"config.changed");assertEquals("4",changed.get("before").getAsString());
|
||||
assertEquals("9",changed.get("after").getAsString());
|
||||
}
|
||||
@Test void bookAcquisitionSneakUseAddsOwnedTemporaryHealthAndExpiryPreservesUnrelatedAbsorption()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,false);f.players.put(a.core.id,a.player);f.activate(a);a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);String book=PartyBookNativeTest.text(a).replaceAll("\\s+","");assertTrue(book.contains("Inspire"));assertTrue(book.contains("CHR12"));assertTrue(book.contains("WIS10"));assertFalse(book.contains("Cost:"));assertTrue(book.contains("12"));assertTrue(book.contains("6s"));PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
double maximum=a.maximumHealth.getBaseValue();var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);assertEquals(maximum,a.maximumHealth.getBaseValue(),1e-9);assertEquals(120L,f.delayedTicks.getLast());f.delayed.getLast().run();assertEquals(2,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
|
||||
@Test void aimedPartyAllyActualAbsorptionCreatesPersistedSupportShare()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));
|
||||
doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3));absorption.set(2.0);PartyBookNativeTest.drain(f);String feedback="Inspire prevented 3 damage to "+b.core.record.name()+".";assertEquals(1,a.messages.stream().filter(feedback::equals).count());assertEquals(1,b.messages.stream().filter(feedback::equals).count());var sourceSheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var holder=new FantasyStickLifecycleNativeTest.Actor(data);holder.core.inventory.setItemInOffHand(sourceSheet);f.players.put(holder.core.id,holder.player);f.activate(holder);
|
||||
var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.valueOf(5),storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());}}
|
||||
@Test void normalSelfOrdinaryOutOfRangeAndObstructedTargetsRefuseBeforeCooldown()throws Exception{for(int route=0;route<4;route++){var dir=data.resolve("refuse-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;if(route==0)target=a.player;else if(route==1){target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}else{var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);f.activate(b);}doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,route==2?13:8,64,0)).when(target).getLocation();doReturn(route!=3).when(a.player).hasLineOfSight(target);doReturn(target).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("inspire"));}}}
|
||||
@Test void normalSelfOrdinaryOutOfRangeAndObstructedTargetsRefuseBeforeCooldown()throws Exception{for(int route=0;route<4;route++){var dir=data.resolve("refuse-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;if(route==0)target=a.player;else if(route==1){target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}else{var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);f.activate(b);}doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,route==2?13:8,64,0)).when(target).getLocation();doReturn(route!=3).when(a.player).hasLineOfSight(target);doReturn(target).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("inspire"));}
|
||||
var own=inspireEvents(dir);assertEquals(0,count(own,"ability.cast"));assertEquals(0,count(own,"effect.applied"));
|
||||
assertEquals("refused",record(own,"ability.resolved").get("outcome").getAsString());
|
||||
}}
|
||||
@Test void nonPartyTargetIsAllowedAndStrongestRemainingCopiesPreserveUnrelatedAbsorption()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var c=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);withInspire(data,c,true);f.players.put(a.core.id,a.player);f.players.put(c.core.id,c.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(c);f.activate(b);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());for(var caster:List.of(a,c)){doReturn(false).when(caster.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(caster.player).getLocation();doReturn(true).when(caster.player).hasLineOfSight(b.player);doReturn(b.player).when(caster.player).getTargetEntity(64);}doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","6"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();use(f,c);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(9,absorption.get(),1e-9);
|
||||
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));absorption.set(6.0);PartyBookNativeTest.drain(f);f.delayed.getLast().run();assertEquals(4,absorption.get(),1e-9);f.delayed.getFirst().run();assertEquals(2,absorption.get(),1e-9);}}
|
||||
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));absorption.set(6.0);PartyBookNativeTest.drain(f);f.delayed.getLast().run();assertEquals(4,absorption.get(),1e-9);f.delayed.getFirst().run();assertEquals(2,absorption.get(),1e-9);}
|
||||
var own=inspireEvents(data);assertEquals(2,count(own,"ability.cast"));assertEquals(2,count(own,"effect.applied"));
|
||||
assertEquals(1,count(own,"combat.prevented"));assertEquals(2,count(own,"effect.ended"));
|
||||
var applied=own.stream().filter(event->event.get("type").getAsString().endsWith("effect.applied")).toList();
|
||||
var prevented=record(own,"combat.prevented");
|
||||
assertEquals(applied.getLast().getAsJsonObject("data").get("application_id").getAsString(),
|
||||
prevented.get("effect_application_id").getAsString(),"strongest accepted grant owns observed absorption");
|
||||
assertEquals("7",applied.getLast().getAsJsonObject("data").get("granted_amount").getAsString());
|
||||
}
|
||||
@Test void cancelledOrSelfOnlyProtectionCannotManufactureSupportShare()throws Exception{for(int route=0;route<2;route++){var dir=data.resolve("no-support-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);var b=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));var target=route==0?b.player:a.player;var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(x->absorption.get()).when(target).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(target).setAbsorptionAmount(anyDouble());doReturn(route==1).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,target,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3);if(route==0)hit.setCancelled(true);f.dispatcher.accept(hit);if(route==1){absorption.set(2.0);PartyBookNativeTest.drain(f);}var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.valueOf(5),storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());}}}
|
||||
@Test void exactFormulaSupportsFiniteUncappedCharisma(){assertEquals(BigInteger.valueOf(5),games.dmg.fantasystick.ability.Inspire.amount(BigInteger.valueOf(12)));assertEquals(BigInteger.valueOf(3),games.dmg.fantasystick.ability.Inspire.amount(BigInteger.valueOf(9)));var huge=BigInteger.TEN.pow(500);assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)),games.dmg.fantasystick.ability.Inspire.amount(huge));}
|
||||
@Test void failedCooldownPersistencePublishesNoTemporaryHealth(){var storage=mock(CharacterStorage.class);var sessions=mock(games.dmg.fantasystick.session.EquipmentSessions.class);var parties=new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(),ignored->true);var temporary=new games.dmg.fantasystick.ability.TemporaryHealth(sessions,parties,Runnable::run,ignored->{});var inspire=new games.dmg.fantasystick.ability.Inspire(games.dmg.fantasystick.ability.Inspire.Settings.defaults(),temporary);var registry=new games.dmg.fantasystick.ability.AbilityRegistry();registry.register(inspire.definition());var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var stats=new EnumMap<>(base.stats());stats.put(Stat.CHR,BigInteger.valueOf(12));stats.put(Stat.WIS,BigInteger.TEN);var record=new CharacterRecord(base.id(),base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),base.health(),Set.of("inspire"),List.of("inspire","","","","","",""),base.cooldowns(),base.restrictedKills(),false,null,null);var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),java.util.UUID.randomUUID(),record.id());var player=mock(org.bukkit.entity.Player.class);when(player.getUniqueId()).thenReturn(active.player());when(player.isSneaking()).thenReturn(true);when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active));when(sessions.current(active)).thenReturn(true);when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));var failed=new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();failed.completeExceptionally(new java.io.IOException("simulated"));when(storage.commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("inspire"),any())).thenReturn(failed);var engine=new games.dmg.fantasystick.ability.AbilityEngine(registry,storage,sessions,parties,java.time.Clock.systemUTC(),Runnable::run,(delay,action)->()->{},ignored->{},ignored->{});engine.accept(new games.dmg.fantasystick.session.ControlInput(player,active,2,"inspire",games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,games.dmg.fantasystick.session.ControlInput.Gesture.USE,player,0,0,()->true));verify(storage).commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("inspire"),any());verify(player,never()).setAbsorptionAmount(anyDouble());engine.close();inspire.close();temporary.close();parties.close();}
|
||||
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedTemporaryHealth()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 60\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var deadline=storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire");a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);var unlearn=button(a,"[Unlearn]","inspire");PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(2,absorption.get(),1e-9);var refunded=storage.find(a.core.record.id()).get().orElseThrow();assertEquals(BigInteger.valueOf(40),refunded.unspentPoints());assertEquals(deadline,refunded.cooldowns().get("inspire"));learnAndAssign(f,a);use(f,a);assertTrue(a.messages.stream().anyMatch(m->m.contains("ready in")));var sheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var b=new FantasyStickLifecycleNativeTest.Actor(data);b.core.inventory.setItemInOffHand(sheet);f.players.put(b.core.id,b.player);f.activate(b);doReturn(true).when(b.player).isSneaking();use(f,b);assertTrue(b.messages.stream().anyMatch(m->m.contains("ready in")));assertEquals(deadline,storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
|
||||
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedTemporaryHealth()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 60\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var deadline=storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire");a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);var unlearn=button(a,"[Unlearn]","inspire");PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(2,absorption.get(),1e-9);var refunded=storage.find(a.core.record.id()).get().orElseThrow();assertEquals(BigInteger.valueOf(40),refunded.unspentPoints());assertEquals(deadline,refunded.cooldowns().get("inspire"));learnAndAssign(f,a);use(f,a);assertTrue(a.messages.stream().anyMatch(m->m.contains("ready in")));var sheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var b=new FantasyStickLifecycleNativeTest.Actor(data);b.core.inventory.setItemInOffHand(sheet);f.players.put(b.core.id,b.player);f.activate(b);doReturn(true).when(b.player).isSneaking();use(f,b);assertTrue(b.messages.stream().anyMatch(m->m.contains("ready in")));assertEquals(deadline,storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}
|
||||
var own=inspireEvents(data);assertEquals(1,count(own,"ability.cast"));
|
||||
assertEquals(1,count(own,"effect.applied"));assertEquals(1,count(own,"effect.ended"));
|
||||
assertEquals(0,count(own,"combat.prevented"));
|
||||
assertEquals("interrupted",record(own,"effect.ended").get("reason").getAsString());
|
||||
assertEquals("5",record(own,"effect.ended").get("remaining_amount").getAsString());
|
||||
}
|
||||
@Test void configuredDefinitionAdminRestartFutureCastAndStartupValidationCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-base-temporary-health: 8\ninspire-cooldown-seconds: 0\ninspire-range-blocks: 16\ninspire-duration-seconds: 8\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var d=registry.find("inspire").orElseThrow();assertEquals(java.time.Duration.ZERO,d.cooldown());assertEquals(new java.math.BigDecimal("8"),d.parameters().get("base-temporary-health"));assertEquals(0,new java.math.BigDecimal("16").compareTo(d.parameters().get("range")));assertEquals(0,new java.math.BigDecimal("8").compareTo(d.parameters().get("duration-seconds")));
|
||||
var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(List.of("inspire-base-temporary-health","inspire-cooldown-seconds","inspire-duration-seconds","inspire-range-blocks"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-"}));assertEquals(List.of("4","8"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health"});assertTrue(sender.messages.getLast().contains("=8"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(new java.math.BigDecimal("9"),registry.find("inspire").orElseThrow().parameters().get("base-temporary-health"));
|
||||
var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(10,absorption.get(),1e-9);assertEquals(160L,f.delayedTicks.getLast());
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","-1"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reload=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new java.math.BigDecimal("9"),reload.find("inspire").orElseThrow().parameters().get("base-temporary-health"));}
|
||||
for(String invalid:List.of("inspire-base-temporary-health: -1\n","inspire-cooldown-seconds: -1\n","inspire-range-blocks: 0\n","inspire-duration-seconds: 0\n")){var dir=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),invalid);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}}
|
||||
private static List<com.google.gson.JsonObject> inspireEvents(Path root)throws Exception{
|
||||
var results=new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try(var days=java.nio.file.Files.list(root.resolve("events"))){
|
||||
for(var day:days.filter(path->path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList())
|
||||
try(var segments=java.nio.file.Files.list(day)){
|
||||
for(var path:segments.filter(file->file.getFileName().toString().startsWith("events-")
|
||||
&&file.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for(var line:java.nio.file.Files.readAllLines(path))
|
||||
if(!line.isBlank()){
|
||||
var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();
|
||||
var data=event.getAsJsonObject("data");
|
||||
if(data!=null&&data.has("ability_id")&&data.get("ability_id").getAsString().equals("inspire"))results.add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
private static long count(List<com.google.gson.JsonObject> own,String suffix){return own.stream().filter(event->event.get("type").getAsString().endsWith(suffix)).count();}
|
||||
private static com.google.gson.JsonObject record(List<com.google.gson.JsonObject> own,String suffix){return own.stream().filter(event->event.get("type").getAsString().endsWith(suffix)).findFirst().orElseThrow().getAsJsonObject("data");}
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));}
|
||||
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor a,String label,String ability){var b=org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast()).get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);assertNotNull(b);for(var p:b.pages()){var x=button(p.raw(),label,ability);if(x!=null)return x;}throw new AssertionError("missing action");}
|
||||
|
||||
@@ -83,6 +83,30 @@ class LongbowNativeTest {
|
||||
assertEquals(BigInteger.TEN, saved.experience()); assertNotNull(saved.cooldowns().get("longbow"));
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target)); assertEquals(1, damages.size());
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
|
||||
var cast = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
var result = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
var damage = own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("hit", result.get("outcome").getAsString());
|
||||
assertEquals("20", cast.get("draw_ticks").getAsString());
|
||||
assertEquals("1", cast.get("draw_power").getAsString());
|
||||
assertEquals("14", cast.get("captured_dex").getAsString());
|
||||
assertTrue(cast.has("cooldown_until"));
|
||||
assertTrue(cast.has("after_revision"));
|
||||
assertTrue(cast.getAsJsonObject("location").has("world_id"));
|
||||
assertEquals(cast.get("execution_id").getAsString(), result.get("execution_id").getAsString());
|
||||
assertEquals(result.get("arrow_id").getAsString(), damage.get("arrow_id").getAsString());
|
||||
assertEquals(cast.get("action_id").getAsString(), damage.get("action_id").getAsString());
|
||||
assertTrue(damage.getAsJsonObject("target_location").has("world_id"));
|
||||
assertTrue(damage.getAsJsonObject("impact_location").has("world_id"));
|
||||
assertEquals(7.0, damage.get("actual_amount").getAsDouble());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void partialDrawUsesReviewedCurveWhileTooShortDrawRefusesBeforeCooldown() throws Exception {
|
||||
@@ -111,6 +135,16 @@ class LongbowNativeTest {
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
|
||||
assertEquals(7.0 * (0.25 + 1.0) / 3.0, amount[0], 1.0e-9);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
|
||||
&& event.getAsJsonObject("data").get("draw_ticks").getAsInt() == 10).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("refused")).count());
|
||||
var damage = own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage"))
|
||||
.map(event -> event.getAsJsonObject("data")).toList();
|
||||
assertEquals(1, damage.size(), "Exactly one accepted partial-draw damage record");
|
||||
assertEquals(7.0 * (0.25 + 1.0) / 3.0, damage.getFirst().get("requested_amount").getAsDouble(), 1.0e-9);
|
||||
}
|
||||
|
||||
@Test void nativeDamagePartyTargetsAndCancelledCustomDamageProduceNoAcceptedHit() throws Exception {
|
||||
@@ -151,6 +185,98 @@ class LongbowNativeTest {
|
||||
assertEquals(BigInteger.ZERO, storage.find(c.core.record.id()).get().orElseThrow().experience());
|
||||
assertNotNull(storage.find(c.core.record.id()).get().orElseThrow().cooldowns().get("longbow"));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
|
||||
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count(),
|
||||
"Native party protection and cancelled custom damage each resolve without an accepted hit");
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void mixedModeNativeProtectionCancelsArrowHitWithoutClaimingDamage() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
ordinary.core.location.setWorld(a.world); ordinary.core.location.setX(4); ordinary.core.location.setY(65.6);
|
||||
f.players.put(a.core.id, a.player); f.players.put(ordinary.core.id, ordinary.player);
|
||||
f.activate(a);
|
||||
var arrow = arrow(a, new Location(a.world, 4, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, ordinary.player));
|
||||
verify(ordinary.player, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void acceptedFantasyPlayerArrowDamageIsNotDuplicatedAsGenericIncomingDamage() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
b.core.location.setWorld(a.world); b.core.location.setX(6); b.core.location.setY(65.6);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b);
|
||||
doReturn(org.bukkit.entity.EntityType.PLAYER).when(b.player).getType();
|
||||
f.dispatcher.accept(new EntityDamageEvent(b.player, EntityDamageEvent.DamageCause.FALL, 1));
|
||||
var arrow = arrow(a, new Location(a.world, 6, 65.6, 0));
|
||||
when(arrow.getType()).thenReturn(org.bukkit.entity.EntityType.ARROW);
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
doAnswer(call -> {
|
||||
var source = call.getArgument(1, DamageSource.class);
|
||||
var hit = new EntityDamageByEntityEvent(arrow, b.player, EntityDamageEvent.DamageCause.PROJECTILE,
|
||||
source, (Double) call.getArgument(0));
|
||||
f.dispatcher.accept(hit); assertFalse(hit.isCancelled()); return null;
|
||||
}).when(b.player).damage(anyDouble(), any(DamageSource.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, b.player));
|
||||
verify(b.player, times(1)).damage(anyDouble(), any(DamageSource.class));
|
||||
f.plugin.setEnabled(false);
|
||||
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
&& event.getAsJsonObject("data").has("target_player_id")
|
||||
&& event.getAsJsonObject("data").get("target_player_id").getAsString().equals(b.core.id.toString())).toList();
|
||||
assertEquals(2, damage.size(), "One unrelated fall and one accepted Longbow shot");
|
||||
assertEquals(1, damage.stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void zeroFinalNativeArrowDamageIsAReportedMissNotAnAcceptedHitOrCancellation() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var arrow = arrow(a, new Location(a.world, 4, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 4, 65.6, 0));
|
||||
doAnswer(call -> {
|
||||
var source = call.getArgument(1, DamageSource.class);
|
||||
var hit = new EntityDamageByEntityEvent(arrow, target, EntityDamageEvent.DamageCause.PROJECTILE,
|
||||
source, 0);
|
||||
f.dispatcher.accept(hit); assertFalse(hit.isCancelled()); return null;
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("miss")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void rangeLifetimeAndSessionEndRemoveOwnedArrowsWithoutLateDamage() throws Exception {
|
||||
@@ -180,6 +306,12 @@ class LongbowNativeTest {
|
||||
verify(arrow, atLeastOnce()).remove();
|
||||
}
|
||||
}
|
||||
var results = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow"))
|
||||
.map(event -> event.getAsJsonObject("data").get("outcome").getAsString()).toList();
|
||||
assertEquals(3, results.size(), results.toString());
|
||||
assertTrue(results.containsAll(java.util.List.of("miss", "expired", "interrupted")), results.toString());
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndTransferRetainRecoveryAndRemoveTheOwnedArrow() throws Exception {
|
||||
@@ -212,6 +344,66 @@ class LongbowNativeTest {
|
||||
verify(b.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.unlearned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void restartAndNewHolderCannotRelaunchACommittedLongbowRecovery() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "longbow-recovery-seconds: 60\n");
|
||||
ItemStack sheet;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var arrow = arrow(a, new Location(a.world, 2, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
verify(a.player, times(1)).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b);
|
||||
drawAndRelease(f, b, 20);
|
||||
verify(b.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void failedNativeRecoverySaveReportsFailureWithoutArrowOrAcceptedDamage() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
storage.close();
|
||||
drawAndRelease(f, a, 20);
|
||||
for (int tries = 0; tries < 4 && a.messages.stream().noneMatch(message -> message.contains("could not be saved")); tries++) {
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(response); response.run();
|
||||
}
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
|
||||
verify(a.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("failed")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
|
||||
|| event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void failedRecoveryPersistenceLaunchesNoArrow() {
|
||||
@@ -244,6 +436,38 @@ class LongbowNativeTest {
|
||||
assertEquals(1, failures.size()); engine.close(); longbow.close(); parties.close();
|
||||
}
|
||||
|
||||
@Test void registeredAuthorizedConfigurationReadAndAcknowledgedSaveReachJsonlButFailureDoesNot() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "longbow-base-damage"});
|
||||
assertTrue(admin.messages.getLast().contains("longbow-base-damage=5"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "longbow-base-damage", "9"});
|
||||
var saved = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(saved); saved.run();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("9"), registry.find("longbow").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "longbow-base-damage", "11"});
|
||||
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed); failed.run();
|
||||
}
|
||||
admin.allowed = false;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "longbow-base-damage"});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")
|
||||
&& (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
|
||||
assertEquals(2, config.size(), "Denied reads and failed writes cannot claim successful config events");
|
||||
assertEquals("5", config.getLast().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("9", config.getLast().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void acknowledgedConfigurationChangesOnlyFutureShotSnapshots() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -391,6 +615,20 @@ class LongbowNativeTest {
|
||||
|
||||
private record DamageTarget(LivingEntity entity, double[] amount) { }
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
|
||||
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var days = java.nio.file.Files.list(data.resolve("events"))) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
|
||||
try (var segments = java.nio.file.Files.list(day)) {
|
||||
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static DamageTarget targetRecordingDamage(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor, Arrow arrow) {
|
||||
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Registered session and native nutrition policy -> exported JSONL, not direct bridge calls. */
|
||||
class NutritionEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void acknowledgedEntryAndExitExportOneOwnedNutritionTransitionEach() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
actor.player.setFoodLevel(7);
|
||||
actor.player.setSaturation(2);
|
||||
actor.player.setExhaustion(3);
|
||||
fixture.activate(actor);
|
||||
assertEquals(20, actor.player.getFoodLevel());
|
||||
awaitType("nutrition.suspended");
|
||||
actor.core.inventory.setItemInOffHand(null);
|
||||
fixture.changed(actor);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertEquals(7, actor.player.getFoodLevel());
|
||||
awaitType("nutrition.restored");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var suspended = matching("nutrition.suspended");
|
||||
var restored = matching("nutrition.restored");
|
||||
assertEquals(1, suspended.size()); assertEquals(1, restored.size());
|
||||
var start = suspended.getFirst().getAsJsonObject("data");
|
||||
var end = restored.getFirst().getAsJsonObject("data");
|
||||
assertEquals(actor.core.id.toString(), suspended.getFirst().get("playerid").getAsString());
|
||||
assertEquals(actor.core.record.id().toString(), suspended.getFirst().get("characterid").getAsString());
|
||||
assertEquals(start.get("session_id"), end.get("session_id"));
|
||||
assertEquals(7, start.get("ordinary_food").getAsInt());
|
||||
assertEquals(2, start.get("ordinary_saturation").getAsFloat());
|
||||
assertEquals(3, start.get("ordinary_exhaustion").getAsFloat());
|
||||
assertEquals(20, start.get("fantasy_food").getAsInt());
|
||||
assertEquals(7, end.get("restored_food").getAsInt());
|
||||
assertEquals("acknowledged", end.get("outcome").getAsString());
|
||||
assertTrue(start.has("receipt_id"));
|
||||
assertTrue(end.has("receipt_id"));
|
||||
assertTrue(suspended.getFirst().getAsJsonObject("data").has("location"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void restartedRecoveryWithoutHealthProofRetainsNutritionWithoutInventedSuccess() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
actor.player.setFoodLevel(7);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
fixture.plugin.setEnabled(false); fixture.awaitWorkers();
|
||||
actor.core.inventory.setItem(0, null);
|
||||
fixture.plugin.setEnabled(true);
|
||||
awaitNutritionResolution(fixture, "retained", "health_unverified");
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, matching("nutrition.restored").size());
|
||||
assertTrue(matching("action.resolved").stream().map(event -> event.getAsJsonObject("data"))
|
||||
.anyMatch(fields -> fields.has("action_kind")
|
||||
&& fields.get("action_kind").getAsString().equals("nutrition_restore")
|
||||
&& fields.get("reason").getAsString().equals("health_unverified")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retainedEquipmentExitDoesNotClaimNutritionSuccessUntilProofBasedRecoveryAcknowledges() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
actor.player.setFoodLevel(7);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertEquals(0, matching("nutrition.restored").size());
|
||||
var attempt = ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "owned"))
|
||||
.get(actor.core.id);
|
||||
assertNotNull(attempt);
|
||||
for (int i = 0; i < 8 && (boolean) FantasyStickLifecycleNativeTest.Fixture.field(attempt, "saving"); i++) {
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
fixture.tick();
|
||||
}
|
||||
assertTrue((boolean) FantasyStickLifecycleNativeTest.Fixture.field(attempt, "nutritionRetainedWritten"),
|
||||
"Refused receipt must resolve native nutrition as retained before retry");
|
||||
awaitNutritionResolution(fixture, "retained");
|
||||
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending());
|
||||
actor.core.inventory.setItem(0, null);
|
||||
sessions.recover(actor.player);
|
||||
awaitTypeWithCallbacks(fixture, "nutrition.restored");
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(1, matching("nutrition.restored").size());
|
||||
assertEquals(7, matching("nutrition.restored").getFirst().getAsJsonObject("data")
|
||||
.get("restored_food").getAsInt());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedRestoreSurvivesWriterOutageWithItsOriginalEventIdentity() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
actor.player.setFoodLevel(7);
|
||||
fixture.activate(actor);
|
||||
awaitType("nutrition.suspended");
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
fixture.plugin.setEnabled(false); fixture.awaitWorkers();
|
||||
java.util.Set<String> pending;
|
||||
try (var files = Files.list(data.resolve("nutrition-event-intents"))) {
|
||||
pending = files.filter(path -> path.getFileName().toString().endsWith(".pending"))
|
||||
.map(path -> path.getFileName().toString().replace(".pending", ""))
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
assertEquals(1, pending.size(), "A restored native snapshot awaits export, not another restoration");
|
||||
fixture.plugin.setEnabled(true);
|
||||
awaitType("nutrition.restored");
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(pending, matching("nutrition.restored").stream()
|
||||
.map(event -> event.get("id").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(1, matching("nutrition.restored").size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void finalDefeatRestorationIsNotMisreportedAsLivingExit() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
actor.player.setFoodLevel(7);
|
||||
fixture.activate(actor);
|
||||
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
|
||||
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(actor.player, source, new ArrayList<>(), 0,
|
||||
net.kyori.adventure.text.Component.empty(), true);
|
||||
fixture.dispatcher.accept(death);
|
||||
DownedSessionNativeTest.acceptDeath(fixture, actor);
|
||||
PartyBookNativeTest.drain(fixture);
|
||||
assertEquals(7, actor.player.getFoodLevel());
|
||||
awaitTypeWithCallbacks(fixture, "nutrition.restored");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var restored = matching("nutrition.restored");
|
||||
assertEquals(1, restored.size());
|
||||
assertEquals("defeat", restored.getFirst().getAsJsonObject("data").get("reason").getAsString());
|
||||
assertEquals(0, matching("character.exited").size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void orderlyDisableReplaysAcknowledgedRestorationAfterLoggerShutdown() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
actor.player.setFoodLevel(7);
|
||||
fixture.activate(actor);
|
||||
awaitType("nutrition.suspended");
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
fixture.plugin.setEnabled(false); fixture.awaitWorkers();
|
||||
assertEquals(7, actor.player.getFoodLevel());
|
||||
assertEquals(0, matching("nutrition.restored").size(),
|
||||
"A best-effort native setter alone does not acknowledge the receipt");
|
||||
assertEquals(1, matching("nutrition.suspended").size());
|
||||
fixture.plugin.setEnabled(true);
|
||||
awaitType("nutrition.restored");
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(1, matching("nutrition.restored").size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void logoutAndDisableNeverClaimTwoSuccessfulRestorations() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
actor.player.setFoodLevel(7);
|
||||
fixture.activate(actor);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(actor.player,
|
||||
net.kyori.adventure.text.Component.empty()));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitTypeWithCallbacks(fixture, "nutrition.restored");
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(1, matching("nutrition.restored").size());
|
||||
assertEquals("logout", matching("nutrition.restored").getFirst().getAsJsonObject("data")
|
||||
.get("reason").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void transferredSheetUsesNewHoldersOrdinaryNutritionAndCapturedIdentity() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var first = actor(fixture);
|
||||
var next = actor(fixture);
|
||||
next.core.inventory.setItemInOffHand(null);
|
||||
first.player.setFoodLevel(7);
|
||||
next.player.setFoodLevel(11);
|
||||
fixture.activate(first);
|
||||
var sheet = first.core.inventory.getItemInOffHand().clone();
|
||||
first.core.inventory.setItemInOffHand(null); fixture.changed(first);
|
||||
fixture.awaitCharacterAndReceipt(first);
|
||||
awaitTypeWithCallbacks(fixture, "nutrition.restored");
|
||||
next.core.inventory.setItemInOffHand(sheet);
|
||||
fixture.activate(next);
|
||||
assertEquals(20, next.player.getFoodLevel());
|
||||
next.core.inventory.setItemInOffHand(null); fixture.changed(next);
|
||||
fixture.awaitCharacterAndReceipt(next);
|
||||
for (int i = 0; i < 250 && matching("nutrition.restored").size() < 2; i++) Thread.sleep(20);
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(2, matching("nutrition.suspended").size());
|
||||
assertEquals(2, matching("nutrition.restored").size());
|
||||
var transferred = matching("nutrition.suspended").stream().filter(event ->
|
||||
event.get("playerid").getAsString().equals(next.core.id.toString())).findFirst().orElseThrow();
|
||||
assertEquals(first.core.record.id().toString(), transferred.get("characterid").getAsString());
|
||||
assertEquals(11, transferred.getAsJsonObject("data").get("ordinary_food").getAsInt());
|
||||
assertEquals(7, first.player.getFoodLevel());
|
||||
assertEquals(11, next.player.getFoodLevel());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void exitingToEatAndReenteringRecordsTwoIndependentOrdinarySnapshots() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
actor.player.setFoodLevel(7);
|
||||
fixture.activate(actor);
|
||||
var sheet = actor.core.inventory.getItemInOffHand().clone();
|
||||
actor.player.setHealth(4);
|
||||
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
|
||||
fixture.awaitCharacterAndReceipt(actor); awaitType("nutrition.restored");
|
||||
actor.player.setFoodLevel(15);
|
||||
actor.core.inventory.setItemInOffHand(sheet); fixture.reenter(actor);
|
||||
assertEquals(4, actor.player.getHealth());
|
||||
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
for (int i = 0; i < 250 && matching("nutrition.restored").size() < 2; i++) Thread.sleep(20);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var suspended = matching("nutrition.suspended");
|
||||
var restored = matching("nutrition.restored");
|
||||
assertEquals(2, suspended.size()); assertEquals(2, restored.size());
|
||||
assertEquals(2, suspended.stream().map(event -> event.getAsJsonObject("data")
|
||||
.get("session_id").getAsString()).distinct().count());
|
||||
assertEquals(java.util.Set.of(7, 15), suspended.stream().map(event -> event.getAsJsonObject("data")
|
||||
.get("ordinary_food").getAsInt()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(java.util.Set.of(7, 15), restored.stream().map(event -> event.getAsJsonObject("data")
|
||||
.get("restored_food").getAsInt()).collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void satiatedRegainIsRefusedWithoutFoodTickSpamOrOrdinaryPlayerAudit() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
var ordinary = actor(fixture);
|
||||
ordinary.core.inventory.setItemInOffHand(null);
|
||||
fixture.activate(actor);
|
||||
var exhaustion = new org.bukkit.event.entity.EntityExhaustionEvent(actor.player,
|
||||
org.bukkit.event.entity.EntityExhaustionEvent.ExhaustionReason.SPRINT, 0.1f);
|
||||
fixture.dispatcher.accept(exhaustion);
|
||||
assertTrue(exhaustion.isCancelled());
|
||||
var foodHeal = new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
|
||||
fixture.dispatcher.accept(foodHeal);
|
||||
assertTrue(foodHeal.isCancelled());
|
||||
var ordinaryHeal = new org.bukkit.event.entity.EntityRegainHealthEvent(ordinary.player, 2,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
|
||||
fixture.dispatcher.accept(ordinaryHeal);
|
||||
assertFalse(ordinaryHeal.isCancelled());
|
||||
awaitType("action.resolved");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var resolved = matching("action.resolved").stream().filter(event ->
|
||||
event.getAsJsonObject("data").has("action_kind")
|
||||
&& event.getAsJsonObject("data").get("action_kind").getAsString().equals("satiated_regeneration"))
|
||||
.toList();
|
||||
assertEquals(1, resolved.size());
|
||||
assertEquals("cancelled", resolved.getFirst().getAsJsonObject("data").get("outcome").getAsString());
|
||||
assertEquals(2, resolved.getFirst().getAsJsonObject("data").get("requested_amount").getAsDouble());
|
||||
assertTrue(resolved.getFirst().getAsJsonObject("data").has("location"));
|
||||
assertEquals(0, matching("combat.healed").size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nonOwnedNativeMagicHealingReportsOnlyAppliedAmountAfterContinuation() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
fixture.activate(actor);
|
||||
actor.player.setHealth(4);
|
||||
var accepted = new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 3,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC);
|
||||
fixture.dispatcher.accept(accepted);
|
||||
assertFalse(accepted.isCancelled());
|
||||
actor.player.setHealth(6); // The actual native health result, not the requested amount.
|
||||
fixture.tick();
|
||||
awaitType("combat.healed");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var healed = matching("combat.healed");
|
||||
assertEquals(1, healed.size());
|
||||
var payload = healed.getFirst().getAsJsonObject("data");
|
||||
assertEquals("MAGIC", payload.get("regain_source").getAsString());
|
||||
assertEquals(4.0, payload.get("health_before").getAsDouble());
|
||||
assertEquals(6.0, payload.get("health_after").getAsDouble());
|
||||
assertEquals(2.0, payload.get("actual_amount").getAsDouble());
|
||||
assertEquals("pending_session_health_save", payload.get("persistence_result").getAsString());
|
||||
assertEquals("native_health_observed", payload.get("publication_result").getAsString());
|
||||
assertEquals(actor.core.record.id().toString(), healed.getFirst().get("characterid").getAsString());
|
||||
assertTrue(payload.has("location"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAuditHealthReadCannotPreventNativeSessionExit() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
actor.player.setFoodLevel(7);
|
||||
fixture.activate(actor);
|
||||
actor.player.setHealth(4);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
|
||||
actor.player.setHealth(6);
|
||||
var reads = new java.util.concurrent.atomic.AtomicInteger();
|
||||
org.mockito.Mockito.doAnswer(call -> {
|
||||
if (reads.incrementAndGet() == 1) throw new IllegalStateException("Audit health read unavailable");
|
||||
return actor.core.player.getHealth();
|
||||
}).when(actor.player).getHealth();
|
||||
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitTypeWithCallbacks(fixture, "nutrition.restored");
|
||||
assertEquals(7, actor.player.getFoodLevel(), "Logging must not block ordinary nutrition restoration");
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, matching("combat.healed").size(), "An unavailable native result is not invented");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sameTickExitStillReportsProvedNativeHealingBeforeTheSessionIsReleased() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
fixture.activate(actor);
|
||||
actor.player.setHealth(4);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
|
||||
actor.player.setHealth(6);
|
||||
var policy = (games.dmg.fantasystick.session.NutritionPolicy)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "nutritionPolicy");
|
||||
assertTrue(((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(policy,
|
||||
"pendingNativeHealing")).containsKey(actor.core.id));
|
||||
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitTypeWithCallbacks(fixture, "nutrition.restored");
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(1, matching("combat.healed").size(),
|
||||
"The actual native heal must not disappear when sheet exit follows in the same tick");
|
||||
assertEquals(2.0, matching("combat.healed").getFirst().getAsJsonObject("data")
|
||||
.get("actual_amount").getAsDouble());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeHealingDisclosesPendingSaveUntilSessionExitPersistsTheActualHealth() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
fixture.activate(actor);
|
||||
actor.player.setHealth(4);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
|
||||
actor.player.setHealth(6);
|
||||
fixture.tick(); awaitType("combat.healed");
|
||||
var healed = matching("combat.healed").getFirst();
|
||||
assertEquals("pending_session_health_save", healed.getAsJsonObject("data")
|
||||
.get("persistence_result").getAsString());
|
||||
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitTypeWithCallbacks(fixture, "nutrition.restored");
|
||||
var characters = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
assertEquals(0, characters.find(actor.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.orElseThrow().health().compareTo(java.math.BigDecimal.valueOf(6)));
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(1, matching("combat.healed").size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void externallyOwnedCustomRegainIsNotMistakenForThisPluginsHealing() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
fixture.activate(actor);
|
||||
actor.player.setHealth(4);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.CUSTOM));
|
||||
actor.player.setHealth(6);
|
||||
fixture.tick();
|
||||
awaitType("combat.healed");
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals("CUSTOM", matching("combat.healed").getFirst().getAsJsonObject("data")
|
||||
.get("regain_source").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledAndUnappliedNativeHealingAndOwnedCustomCauseDoNotMintSuccess() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
fixture.activate(actor);
|
||||
actor.player.setHealth(4);
|
||||
var cancelled = new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 3,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC);
|
||||
cancelled.setCancelled(true);
|
||||
fixture.dispatcher.accept(cancelled);
|
||||
var unapplied = new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC);
|
||||
fixture.dispatcher.accept(unapplied);
|
||||
var owned = new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
var policy = (games.dmg.fantasystick.session.NutritionPolicy)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "nutritionPolicy");
|
||||
policy.dispatchOwnedHealing(owned, fixture.dispatcher);
|
||||
fixture.tick();
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, matching("combat.healed").size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ownedHealBetweenNativeObservationAndContinuationCannotBeMisattributed() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
fixture.activate(actor);
|
||||
actor.player.setHealth(4);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 3,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
|
||||
var policy = (games.dmg.fantasystick.session.NutritionPolicy)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "nutritionPolicy");
|
||||
policy.dispatchOwnedHealing(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.CUSTOM), fixture.dispatcher);
|
||||
actor.player.setHealth(6);
|
||||
fixture.tick();
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, matching("combat.healed").size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void competingNativeHealObservationsCannotBeMisattributedToTheFirstRequest() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
fixture.activate(actor);
|
||||
actor.player.setHealth(4);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
|
||||
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC_REGEN));
|
||||
actor.player.setHealth(6);
|
||||
fixture.tick();
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, matching("combat.healed").size(),
|
||||
"An overlapping native continuation has no attributable per-effect delta");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void interveningDamageCannotBeMistakenForAnActualNativeHealAmount() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
fixture.activate(actor);
|
||||
actor.player.setHealth(4);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 3,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
|
||||
actor.player.setHealth(7);
|
||||
var damage = new org.bukkit.event.entity.EntityDamageEvent(actor.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE,
|
||||
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
|
||||
fixture.dispatcher.accept(damage);
|
||||
assertFalse(damage.isCancelled());
|
||||
actor.player.setHealth(6);
|
||||
fixture.tick();
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, matching("combat.healed").size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedNativeFoodSetterCannotClaimSuccessfulSuspensionOrRestoration() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
actor.player.setFoodLevel(7);
|
||||
actor.foodMutation = () -> {
|
||||
actor.foodMutation = () -> {};
|
||||
throw new IllegalStateException("Injected food setter failure");
|
||||
};
|
||||
fixture.activate(actor);
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, matching("nutrition.suspended").size());
|
||||
assertEquals(0, matching("nutrition.restored").size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void incompleteNativeRestorationKeepsReceiptAndRecordsRetainedNotSuccess() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = actor(fixture);
|
||||
actor.player.setFoodLevel(7);
|
||||
fixture.activate(actor);
|
||||
actor.foodMutation = () -> {
|
||||
actor.foodMutation = () -> {};
|
||||
throw new IllegalStateException("Injected restoration food setter failure");
|
||||
};
|
||||
actor.core.inventory.setItemInOffHand(null);
|
||||
fixture.changed(actor);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitNutritionResolution(fixture, "retained");
|
||||
var state = fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
|
||||
assertNotNull(state.pending());
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, matching("nutrition.restored").size());
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitNutritionResolution(FantasyStickLifecycleNativeTest.Fixture fixture, String outcome) throws Exception {
|
||||
awaitNutritionResolution(fixture, outcome, null);
|
||||
}
|
||||
|
||||
private void awaitNutritionResolution(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
String outcome, String reason) throws Exception {
|
||||
for (int i = 0; i < 250 && !nutritionResolution(outcome, reason); i++) {
|
||||
var task = fixture.tasks.poll(20, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
if (task == null) Thread.sleep(20);
|
||||
else task.run();
|
||||
}
|
||||
assertTrue(nutritionResolution(outcome, reason),
|
||||
"No nutrition resolution " + outcome + "/" + reason + "; recovery=" + matching("recovery.resolved"));
|
||||
}
|
||||
|
||||
private boolean nutritionResolution(String outcome, String reason) throws Exception {
|
||||
return matching("action.resolved").stream().map(event -> event.getAsJsonObject("data"))
|
||||
.anyMatch(fields -> fields.has("action_kind")
|
||||
&& fields.get("action_kind").getAsString().equals("nutrition_restore")
|
||||
&& fields.get("outcome").getAsString().equals(outcome)
|
||||
&& (reason == null || fields.get("reason").getAsString().equals(reason)));
|
||||
}
|
||||
|
||||
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture fixture) throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
return actor;
|
||||
}
|
||||
|
||||
private void awaitTypeWithCallbacks(FantasyStickLifecycleNativeTest.Fixture fixture, String suffix) throws Exception {
|
||||
for (int i = 0; i < 250 && matching(suffix).isEmpty(); i++) {
|
||||
var task = fixture.tasks.poll(20, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
if (task != null) task.run();
|
||||
}
|
||||
assertFalse(matching(suffix).isEmpty(), "No acknowledged " + suffix + " after native callbacks");
|
||||
}
|
||||
|
||||
private void awaitType(String suffix) throws Exception {
|
||||
for (int i = 0; i < 250 && matching(suffix).isEmpty(); i++) Thread.sleep(20);
|
||||
assertFalse(matching(suffix).isEmpty(), "No exported " + suffix + " from registered native path");
|
||||
}
|
||||
|
||||
private List<JsonObject> matching(String suffix) throws Exception {
|
||||
return records().stream().filter(value -> value.get("type").getAsString().endsWith(suffix)).toList();
|
||||
}
|
||||
|
||||
private List<JsonObject> records() throws Exception {
|
||||
var found = new ArrayList<JsonObject>();
|
||||
var directory = data.resolve("events");
|
||||
if (!Files.exists(directory)) return found;
|
||||
try (var days = Files.list(directory)) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
|
||||
&& Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
|
||||
try (var segments = Files.list(day)) {
|
||||
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
var content = Files.readString(path);
|
||||
int end = content.lastIndexOf('\n');
|
||||
if (end < 0) continue;
|
||||
for (var line : content.substring(0, end).lines().toList())
|
||||
if (!line.isBlank()) found.add(JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,78 @@ class OutOfCombatRecoveryNativeTest {
|
||||
|
||||
@Test void eventSinkFailureCannotChangeAnAcceptedRecoveryPulse(){var f=new Direct();f.recovery.attachBonusHealing(ignored->BigInteger.valueOf(3));f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(event->{throw new IllegalStateException("disk");},f.clock),ignored->BigInteger.valueOf(3));assertDoesNotThrow(()->{f.recovery.sweep();f.clock.advance(Duration.ofSeconds(10));f.recovery.sweep();f.runNext();});assertEquals(8,f.health.get());assertEquals(0,new BigDecimal("8").compareTo(f.record.get().health()));}
|
||||
|
||||
@Test void unloadedAuditWorldCannotInterruptAcceptedOrRefusedRecovery() {
|
||||
var f = new Direct();
|
||||
var captured = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
|
||||
f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(captured::add, f.clock), ignored -> BigInteger.ZERO);
|
||||
var location = mock(org.bukkit.Location.class);
|
||||
when(location.getWorld()).thenThrow(new IllegalArgumentException("World unloaded"));
|
||||
when(f.player.getLocation()).thenReturn(location);
|
||||
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(10));
|
||||
assertDoesNotThrow(() -> { f.recovery.sweep(); f.runNext(); });
|
||||
assertEquals(5, f.health.get());
|
||||
assertEquals(0, BigDecimal.valueOf(5).compareTo(f.record.get().health()));
|
||||
assertEquals(List.of("action.observed", "action.resolved", "combat.healed"),
|
||||
captured.stream().map(games.dmg.fantasystick.events.EventInput::type).toList());
|
||||
captured.forEach(input -> {
|
||||
assertEquals(f.active.player(), input.context().playerId());
|
||||
assertEquals(f.active.character(), input.context().characterId());
|
||||
assertEquals(f.active.session(), input.context().sessionId());
|
||||
assertNull(input.context().location());
|
||||
assertEquals("unavailable", input.data().get("location_status"));
|
||||
});
|
||||
f.clock.advance(Duration.ofSeconds(9)); f.recovery.sweep(); assertEquals(1, f.events.size());
|
||||
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep(); f.runNext();
|
||||
assertEquals(6, f.health.get(), "The next interval is not suppressed or replayed");
|
||||
|
||||
var cancelled = new Direct(); cancelled.veto = true;
|
||||
var refusals = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
|
||||
cancelled.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(refusals::add, cancelled.clock), ignored -> BigInteger.ZERO);
|
||||
when(cancelled.player.getLocation()).thenReturn(location);
|
||||
cancelled.recovery.sweep(); cancelled.clock.advance(Duration.ofSeconds(10)); cancelled.recovery.sweep();
|
||||
assertEquals(4, cancelled.health.get());
|
||||
assertEquals(List.of("action.observed", "action.resolved"),
|
||||
refusals.stream().map(games.dmg.fantasystick.events.EventInput::type).toList());
|
||||
assertEquals("refused", refusals.getLast().data().get("outcome"));
|
||||
assertEquals("unavailable", refusals.getLast().data().get("location_status"));
|
||||
}
|
||||
|
||||
@Test void availableAuditLocationRetainsItsExactWorldAndCoordinates() {
|
||||
var f = new Direct();
|
||||
var captured = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
|
||||
f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(captured::add, f.clock), ignored -> BigInteger.ZERO);
|
||||
var world = mock(org.bukkit.World.class); var worldId = java.util.UUID.randomUUID();
|
||||
when(world.getUID()).thenReturn(worldId); when(world.getName()).thenReturn("known-world");
|
||||
var location = mock(org.bukkit.Location.class);
|
||||
when(location.getWorld()).thenReturn(world);
|
||||
when(location.getX()).thenReturn(2.5); when(location.getY()).thenReturn(65d); when(location.getZ()).thenReturn(-3d);
|
||||
when(f.player.getLocation()).thenReturn(location);
|
||||
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(10)); f.recovery.sweep(); f.runNext();
|
||||
assertEquals(5, f.health.get());
|
||||
assertEquals(worldId, captured.getFirst().context().location().worldId());
|
||||
assertEquals("known-world", captured.getFirst().context().location().worldName());
|
||||
assertEquals(2.5, captured.getFirst().context().location().x());
|
||||
assertEquals(65d, captured.getFirst().context().location().y());
|
||||
assertEquals(-3d, captured.getFirst().context().location().z());
|
||||
assertFalse(captured.getFirst().data().containsKey("location_status"));
|
||||
}
|
||||
|
||||
@Test void auditLocationCaptureFailureAndFailedSaveNeverClaimHealing() {
|
||||
var f = new Direct(); f.automatic = false;
|
||||
var captured = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
|
||||
f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(captured::add, f.clock), ignored -> BigInteger.ZERO);
|
||||
when(f.player.getLocation()).thenThrow(new IllegalStateException("location inaccessible"));
|
||||
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(10));
|
||||
assertDoesNotThrow(f.recovery::sweep);
|
||||
assertNotNull(f.pending);
|
||||
f.pending.completeExceptionally(new java.io.IOException("save failed")); f.runNext();
|
||||
assertEquals(4, f.health.get());
|
||||
assertEquals(List.of("action.observed", "action.resolved"),
|
||||
captured.stream().map(games.dmg.fantasystick.events.EventInput::type).toList());
|
||||
assertEquals("failed", captured.getLast().data().get("outcome"));
|
||||
assertEquals("unavailable", captured.getLast().data().get("location_status"));
|
||||
}
|
||||
|
||||
@Test void passiveBonusZeroCancellationAndFullHealthStayInTheBasePulseTransaction() {
|
||||
var zero = new Direct(); zero.recovery.attachBonusHealing(ignored -> BigInteger.ZERO);
|
||||
zero.recovery.sweep(); zero.clock.advance(Duration.ofSeconds(10)); zero.recovery.sweep(); zero.runNext();
|
||||
@@ -168,6 +240,48 @@ class OutOfCombatRecoveryNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredRecoveryWithUnloadedAuditWorldPersistsAndExportsUnavailableLocation() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 10\n");
|
||||
java.util.UUID playerId; java.util.UUID characterId;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); setHealth(data, a, BigDecimal.valueOf(4));
|
||||
playerId = a.core.id; characterId = a.core.record.id();
|
||||
f.players.put(playerId, a.player); f.activate(a);
|
||||
var location = mock(org.bukkit.Location.class);
|
||||
when(location.getWorld()).thenThrow(new IllegalArgumentException("World unloaded"));
|
||||
doReturn(location).when(a.player).getLocation();
|
||||
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
|
||||
assertDoesNotThrow(recovery::sweep);
|
||||
doReturn(new org.bukkit.Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(5, a.player.getHealth());
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(0, BigDecimal.valueOf(5).compareTo(storage.find(characterId).get().orElseThrow().health()));
|
||||
}
|
||||
var healed = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
|
||||
for (var segment : files.filter(file -> file.getFileName().toString().startsWith("events-")
|
||||
&& file.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
for (var line : java.nio.file.Files.readAllLines(segment)) {
|
||||
if (line.isBlank()) continue;
|
||||
var event = com.google.gson.JsonParser.parseString(line).getAsJsonObject();
|
||||
if (event.get("type").getAsString().endsWith("combat.healed")
|
||||
&& event.getAsJsonObject("data").has("recovery_kind")) healed.add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(1, healed.size());
|
||||
var event = healed.getFirst(); var payload = event.getAsJsonObject("data");
|
||||
assertEquals(playerId.toString(), payload.get("player_id").getAsString());
|
||||
assertEquals(characterId.toString(), payload.get("character_id").getAsString());
|
||||
assertTrue(payload.has("session_id"));
|
||||
assertEquals("unavailable", payload.get("location_status").getAsString());
|
||||
assertFalse(payload.has("location"));
|
||||
assertEquals("5.0", payload.get("health_after").getAsString());
|
||||
}
|
||||
|
||||
@Test void packagedPluginConnectsZeroDelayRecoveryConfigurationAndRestart() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 10\nout-of-combat-healing-amount: 1\n");
|
||||
|
||||
@@ -45,10 +45,32 @@ class QuickstepNativeTest {
|
||||
assertEquals(new Vector(1, 0.25, 0), velocity.get());
|
||||
verify(a.player, never()).setFallDistance(anyFloat());
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"));
|
||||
var from = new Location(a.world, 0, 64, 0); var to = new Location(a.world, 4, 64, 0);
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, to));
|
||||
var from = a.core.location.clone(); var to = from.clone().add(4, 0, 0);
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, from.clone().add(1, 0, 0)));
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from.clone().add(1, 0, 0), from.clone().add(2, 0, 0)));
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from.clone().add(2, 0, 0), to));
|
||||
assertEquals(new Vector(0, 0.25, 0), velocity.get(), "Only remaining owned horizontal motion is removed");
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
|
||||
var cast = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
var result = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("completed", result.get("outcome").getAsString());
|
||||
assertEquals("12", cast.get("captured_dex").getAsString());
|
||||
assertEquals(2, cast.get("slot").getAsInt());
|
||||
assertEquals(4, cast.get("intended_distance").getAsDouble());
|
||||
assertEquals(1, cast.getAsJsonObject("accepted_velocity").get("x").getAsDouble());
|
||||
assertTrue(cast.getAsJsonObject("from_location").has("world_id"));
|
||||
assertEquals(cast.getAsJsonObject("from_location").get("x").getAsDouble() + 4,
|
||||
result.getAsJsonObject("to_location").get("x").getAsDouble());
|
||||
assertEquals(4, result.get("horizontal_distance").getAsDouble());
|
||||
assertEquals(cast.get("action_id").getAsString(), result.get("action_id").getAsString());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")).count());
|
||||
}
|
||||
|
||||
@Test void airborneFlyingGlidingOrMountedUseRefusesBeforeCooldownAndMovement() throws Exception {
|
||||
@@ -68,6 +90,14 @@ class QuickstepNativeTest {
|
||||
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("quickstep"));
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("not eligible")));
|
||||
}
|
||||
for (int route = 0; route < 4; route++) {
|
||||
var own = events(data.resolve(Integer.toString(route))).stream()
|
||||
.filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("refused")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void velocityCancellationObstacleAndUnrelatedReplacementAreAuthoritative() throws Exception {
|
||||
@@ -92,17 +122,60 @@ class QuickstepNativeTest {
|
||||
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Cancelled velocity applies no movement");
|
||||
} else if (route == 1) {
|
||||
velocity.set(new Vector(0, 0.3, 0));
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
|
||||
new Location(a.world, 0.2, 64, 0)));
|
||||
var from = a.core.location.clone();
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, from.clone().add(0.2, 0, 0)));
|
||||
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Native collision stops movement early");
|
||||
} else {
|
||||
velocity.set(new Vector(0, 0.6, 2));
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
|
||||
new Location(a.world, 0.2, 64, 0)));
|
||||
var from = a.core.location.clone();
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, from.clone().add(0.2, 0, 0)));
|
||||
assertEquals(new Vector(0, 0.6, 2), velocity.get(), "Replacement movement relinquishes ownership");
|
||||
}
|
||||
verify(a.player, never()).setFallDistance(anyFloat());
|
||||
}
|
||||
for (int route = 0; route < 3; route++) {
|
||||
var own = events(data.resolve("motion-" + route)).stream()
|
||||
.filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
|
||||
var cast = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
var result = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(new String[]{"cancelled", "obstructed", "replaced"}[route],
|
||||
result.get("outcome").getAsString());
|
||||
assertEquals(route == 0 ? 0 : 0.2, result.get("horizontal_distance").getAsDouble(), 1.0e-8);
|
||||
assertEquals(cast.getAsJsonObject("from_location").get("x").getAsDouble() + (route == 0 ? 0 : 0.2),
|
||||
result.getAsJsonObject("to_location").get("x").getAsDouble(), 1.0e-8);
|
||||
assertEquals(route == 0 ? "cancelled" : "accepted",
|
||||
cast.get("native_velocity_status").getAsString());
|
||||
assertEquals(route != 0, cast.has("accepted_velocity"));
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void crossWorldMoveInterruptsOwnedDashWithoutInventingComparableTravel() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var velocity = prepareMovement(a, new Vector(0, 0.25, 0));
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var otherWorld = mock(org.bukkit.World.class);
|
||||
when(otherWorld.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(otherWorld.getName()).thenReturn("another-test-world");
|
||||
var from = a.core.location.clone();
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, new Location(otherWorld, 123, 70, -8)));
|
||||
assertEquals(new Vector(0, 0.25, 0), velocity.get());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var result = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("interrupted", result.get("outcome").getAsString());
|
||||
assertEquals("another-test-world", result.getAsJsonObject("to_location").get("world_name").getAsString());
|
||||
assertEquals("not_comparable_cross_world", result.get("horizontal_distance_status").getAsString());
|
||||
assertFalse(result.has("horizontal_distance"));
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedHorizontalMotion() throws Exception {
|
||||
@@ -129,6 +202,90 @@ class QuickstepNativeTest {
|
||||
f.players.put(b.core.id, b.player); f.activate(b); prepareMovement(b, new Vector(0.2, 0.3, 0.4)); use(f, b);
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.unlearned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
var interruption = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("interrupted")).toList();
|
||||
assertEquals(1, interruption.size());
|
||||
assertTrue(interruption.getFirst().getAsJsonObject("data").getAsJsonObject("to_location").has("world_id"));
|
||||
}
|
||||
|
||||
@Test void restartedNewHolderCannotRepeatCommittedDashBeforeTheOriginalDeadline() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "quickstep-cooldown-seconds: 60\n");
|
||||
org.bukkit.inventory.ItemStack sheet;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); prepareMovement(a, new Vector(0, 0.25, 0));
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
verify(a.player, times(1)).setVelocity(new Vector(1, 0.25, 0));
|
||||
sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b);
|
||||
prepareMovement(b, new Vector(0, 0.3, 0)); use(f, b);
|
||||
verify(b.player, never()).setVelocity(any(Vector.class));
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
var outcomes = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
|
||||
.map(event -> event.getAsJsonObject("data").get("outcome").getAsString()).toList();
|
||||
assertEquals(2, outcomes.size(), outcomes.toString());
|
||||
assertEquals(java.util.Set.of("completed", "refused"), java.util.Set.copyOf(outcomes),
|
||||
"Original bounded window ended before transfer; restart only refuses its retained deadline");
|
||||
}
|
||||
|
||||
@Test void airborneAfterCooldownAcknowledgmentCannotClaimCompletedMovement() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); prepareMovement(a, new Vector(0, 0.4, 0));
|
||||
use(f, a);
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response);
|
||||
doReturn(false).when(a.player).isOnGround(); response.run();
|
||||
verify(a.player, never()).setVelocity(any(Vector.class));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
|
||||
var cast = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("cancelled", cast.get("native_velocity_status").getAsString());
|
||||
assertTrue(cast.getAsJsonObject("from_location").has("world_id"));
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
|
||||
}
|
||||
|
||||
@Test void failedNativeCooldownSaveEmitsFailureWithoutAClaimedDashOrVelocity() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); prepareMovement(a, new Vector(0, 0.4, 0));
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
storage.close();
|
||||
use(f, a);
|
||||
for (int tries = 0; tries < 4 && a.messages.stream().noneMatch(message -> message.contains("could not be saved")); tries++) {
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(response); response.run();
|
||||
}
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
|
||||
verify(a.player, never()).setVelocity(any(Vector.class));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("failed")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceAppliesNoVelocity() {
|
||||
@@ -166,6 +323,38 @@ class QuickstepNativeTest {
|
||||
engine.close(); quickstep.close(); parties.close();
|
||||
}
|
||||
|
||||
@Test void authorizedRegisteredConfigurationReadAndAcknowledgedChangeReachJsonlButFailuresDoNot() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "quickstep-distance-blocks"});
|
||||
assertTrue(admin.messages.getLast().contains("quickstep-distance-blocks=4"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "quickstep-distance-blocks", "7"});
|
||||
var saved = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(saved); saved.run();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("7"), registry.find("quickstep").orElseThrow()
|
||||
.parameters().get("distance"));
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "quickstep-distance-blocks", "9"});
|
||||
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed); failed.run();
|
||||
}
|
||||
admin.allowed = false;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "quickstep-distance-blocks"});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")
|
||||
&& (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
|
||||
assertEquals(2, config.size());
|
||||
assertEquals("4", config.getLast().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("7", config.getLast().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void acknowledgedUpdatesAffectFutureDashesWhileActiveDashKeepsCapturedBounds() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -240,6 +429,20 @@ class QuickstepNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
|
||||
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var days = java.nio.file.Files.list(data.resolve("events"))) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
|
||||
try (var segments = java.nio.file.Files.list(day)) {
|
||||
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
|
||||
@@ -25,10 +25,16 @@ class SecondWindNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionActualNativeHealPersistsBeforePublicationAndRetainsCooldown() throws Exception {
|
||||
java.util.UUID expectedPlayer, expectedCharacter, expectedSession, expectedWorld;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, false, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
expectedPlayer = a.core.id; expectedCharacter = a.core.record.id();
|
||||
expectedSession = ((games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"))
|
||||
.active(expectedPlayer).orElseThrow().session();
|
||||
expectedWorld = a.core.location.getWorld().getUID();
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("SecondWind")); assertTrue(text.contains("CON12")); assertTrue(text.contains("WIS12"));
|
||||
@@ -57,6 +63,34 @@ class SecondWindNativeTest {
|
||||
learnAndAssign(f, a); use(f, a);
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("second_wind")).toList();
|
||||
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
|
||||
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.unlearned")).count());
|
||||
var cast = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
var healed = own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(2, cast.get("slot").getAsInt());
|
||||
assertEquals("12", cast.get("captured_wis").getAsString());
|
||||
assertTrue(cast.has("cooldown_until"));
|
||||
assertEquals(cast.get("action_id").getAsString(), healed.get("action_id").getAsString());
|
||||
assertEquals(expectedPlayer.toString(), cast.get("player_id").getAsString());
|
||||
assertEquals(expectedCharacter.toString(), cast.get("character_id").getAsString());
|
||||
assertEquals(expectedSession.toString(), cast.get("session_id").getAsString());
|
||||
assertEquals(expectedPlayer.toString(), healed.get("target_player_id").getAsString());
|
||||
assertEquals(expectedCharacter.toString(), healed.get("target_character_id").getAsString());
|
||||
assertEquals(expectedSession.toString(), healed.get("target_session_id").getAsString());
|
||||
assertEquals(expectedWorld.toString(), healed.getAsJsonObject("target_location").get("world_id").getAsString());
|
||||
assertEquals("4", healed.get("health_before").getAsString());
|
||||
assertEquals("11", healed.get("health_after").getAsString());
|
||||
assertEquals("7", healed.get("actual_amount").getAsString());
|
||||
assertEquals("7", healed.get("requested_amount").getAsString());
|
||||
assertTrue(healed.getAsJsonObject("target_location").has("world_id"));
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("healed")).count());
|
||||
}
|
||||
|
||||
@Test void fullHealthAndCancelledNativeHealingApplyNoHealth() throws Exception {
|
||||
@@ -82,6 +116,114 @@ class SecondWindNativeTest {
|
||||
if (full) assertFalse(saved.cooldowns().containsKey("second_wind"));
|
||||
else assertNotNull(saved.cooldowns().get("second_wind"));
|
||||
}
|
||||
for (boolean full : new boolean[]{true, false}) {
|
||||
var own = events(data.resolve(Boolean.toString(full))).stream()
|
||||
.filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("second_wind")).toList();
|
||||
assertEquals(full ? 0 : 1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals(full ? "refused" : "cancelled")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeAdjustedHealIsAuditedDespiteDisabledRoutineFeedback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withSecondWind(data, a, true, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
assertTrue(f.feedbackCommand.execute(a.player, "fantasyfeedback", new String[]{"off"}));
|
||||
f.tick();
|
||||
class Reduce implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler public void heal(EntityRegainHealthEvent event) {
|
||||
if (event.getEntity() == a.player && event.getRegainReason() == EntityRegainHealthEvent.RegainReason.CUSTOM)
|
||||
event.setAmount(2);
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Reduce(), f.plugin);
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(6, a.player.getHealth());
|
||||
assertFalse(a.messages.stream().anyMatch(message -> message.contains("Second Wind healed")));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("second_wind")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
var healed = own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("7", healed.get("requested_amount").getAsString());
|
||||
assertEquals("2", healed.get("actual_amount").getAsString());
|
||||
assertEquals("6", healed.get("health_after").getAsString());
|
||||
}
|
||||
|
||||
@Test void rejectedEventStoreDoesNotChangeApprovedSelfHealingOrDurableHealth() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withSecondWind(data, a, true, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var eventLog = (games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
|
||||
eventLog.close();
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(11, a.player.getHealth());
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(0, BigDecimal.valueOf(11).compareTo(storage.find(a.core.record.id()).get().orElseThrow().health()));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertEquals(0, events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed"))
|
||||
.count(), "A rejected audit record cannot manufacture healing or undo real native health");
|
||||
}
|
||||
|
||||
@Test void staleHealthRevisionAfterCooldownCannotClaimAHealOrOverwriteAnotherWrite() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withSecondWind(data, a, true, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
|
||||
var cooldownResponse = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(cooldownResponse);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var before = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertNotNull(before.cooldowns().get("second_wind"));
|
||||
assertTrue(storage.saveHealth(before.id(), before.revision(), BigDecimal.valueOf(5),
|
||||
BigDecimal.valueOf(1024)).get().isPresent());
|
||||
cooldownResponse.run();
|
||||
var healthResponse = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(healthResponse);
|
||||
healthResponse.run();
|
||||
assertEquals(4, a.player.getHealth(), "Stale Second Wind cannot publish native health");
|
||||
assertEquals(0, BigDecimal.valueOf(5).compareTo(storage.find(before.id()).get().orElseThrow().health()));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("second_wind")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("failed")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
}
|
||||
|
||||
@Test void failedHealthSaveAfterAcknowledgedCooldownNeverClaimsNativeHealing() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withSecondWind(data, a, true, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
use(f, a);
|
||||
var cooldownResponse = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(cooldownResponse, "Cooldown must commit before the health-save failure");
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
storage.close(); cooldownResponse.run();
|
||||
var healthResponse = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(healthResponse); healthResponse.run();
|
||||
assertEquals(4, a.player.getHealth(), "Failed health save cannot publish native health");
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("second_wind")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("failed")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
}
|
||||
|
||||
@Test void failedHealthPersistencePublishesNoNativeHealing() {
|
||||
@@ -116,6 +258,38 @@ class SecondWindNativeTest {
|
||||
verify(player, never()).setHealth(anyDouble()); verify(execution).finish();
|
||||
}
|
||||
|
||||
@Test void authorizedRegisteredConfigurationReadAndAcknowledgedChangeReachJsonlOnlyAfterSave() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "second-wind-base-healing"});
|
||||
assertTrue(admin.messages.getLast().contains("second-wind-base-healing=6"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "second-wind-base-healing", "9"});
|
||||
var saved = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(saved); saved.run();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new BigDecimal("9"), registry.find("second_wind").orElseThrow()
|
||||
.parameters().get("base-healing"));
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "second-wind-base-healing", "11"});
|
||||
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed); failed.run();
|
||||
}
|
||||
admin.allowed = false;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "second-wind-base-healing"});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("second_wind")
|
||||
&& (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
|
||||
assertEquals(2, config.size(), "Denied reads and failed writes cannot claim success");
|
||||
assertEquals("6", config.getLast().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("9", config.getLast().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionFormulaAndAdminRoutesCoverBothSettings() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"second-wind-base-healing: 8\nsecond-wind-cooldown-seconds: 20\n");
|
||||
@@ -168,6 +342,20 @@ class SecondWindNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
|
||||
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var days = java.nio.file.Files.list(data.resolve("events"))) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
|
||||
try (var segments = java.nio.file.Files.list(day)) {
|
||||
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
|
||||
@@ -18,6 +18,20 @@ class SessionRecoveryEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void recoveryReaderCountsOnlyExportedUtcDaySegments() throws Exception {
|
||||
Path events = data.resolve("events");
|
||||
Path pending = events.resolve("pending");
|
||||
Path exported = events.resolve(java.time.LocalDate.now(java.time.ZoneOffset.UTC).toString());
|
||||
Files.createDirectories(pending);
|
||||
Files.createDirectories(exported);
|
||||
Files.writeString(pending.resolve("unexported.jsonl"), "{\"type\":\"phantom.recovery\"}\n");
|
||||
Files.writeString(exported.resolve("events-test-0.jsonl"), "{\"type\":\"recovery.resolved\"}\n");
|
||||
|
||||
var records = records();
|
||||
assertEquals(1, records.size(), "Pending records are not exported delivery");
|
||||
assertEquals("recovery.resolved", records.getFirst().get("type").getAsString());
|
||||
}
|
||||
|
||||
@Test void registeredAuthorizedInspectionOfPendingReceiptIsRecordedWithoutEquipmentContents() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
@@ -502,13 +516,19 @@ class SessionRecoveryEventsNativeTest {
|
||||
var result = new ArrayList<JsonObject>();
|
||||
Path directory = data.resolve("events");
|
||||
if (!Files.exists(directory)) return result;
|
||||
try (var files = Files.walk(directory)) {
|
||||
for (var path : files.filter(item -> item.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
var contents = Files.readString(path);
|
||||
int end = contents.lastIndexOf('\n');
|
||||
if (end < 0) continue;
|
||||
for (var line : contents.substring(0, end).lines().toList())
|
||||
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
|
||||
try (var days = Files.list(directory)) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString()
|
||||
.matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
|
||||
try (var segments = Files.list(day)) {
|
||||
for (var path : segments.filter(item -> item.getFileName().toString().startsWith("events-")
|
||||
&& item.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
var contents = Files.readString(path);
|
||||
int end = contents.lastIndexOf('\n');
|
||||
if (end < 0) continue;
|
||||
for (var line : contents.substring(0, end).lines().toList())
|
||||
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -25,10 +25,15 @@ class ShieldBashNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionAndEquippedAttackReplaceVanillaDamageThenKnockBack() throws Exception {
|
||||
java.util.UUID playerId, characterId, sessionId, targetId, worldId;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withShieldBash(data, a, false);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
playerId = a.core.id; characterId = a.core.record.id();
|
||||
sessionId = ((games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"))
|
||||
.active(playerId).orElseThrow().session();
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(book.contains("ShieldBash")); assertTrue(book.contains("STR12")); assertTrue(book.contains("CON12"));
|
||||
@@ -46,18 +51,23 @@ class ShieldBashNativeTest {
|
||||
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
|
||||
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
targetId = java.util.UUID.randomUUID(); when(target.getUniqueId()).thenReturn(targetId);
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
worldId = java.util.UUID.randomUUID(); when(a.world.getUID()).thenReturn(worldId);
|
||||
var origin = new Location(a.world, 0, 64, 0); var destination = new Location(a.world, 2, 64, 0);
|
||||
doReturn(origin).when(a.player).getLocation(); when(target.getLocation()).thenReturn(destination);
|
||||
doReturn(true).when(a.player).hasLineOfSight(target); when(target.getVelocity()).thenReturn(new Vector(0.1, 0.2, 0));
|
||||
var targetHealth = new java.util.concurrent.atomic.AtomicReference<>(20.0);
|
||||
when(target.getHealth()).thenAnswer(ignored -> targetHealth.get());
|
||||
var applied = new java.util.concurrent.atomic.AtomicReference<Vector>();
|
||||
doAnswer(call -> { applied.set(call.getArgument(0)); return null; }).when(target).setVelocity(any(Vector.class));
|
||||
var damages = new java.util.ArrayList<EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var event = new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0)); damages.add(event); f.dispatcher.accept(event); return null;
|
||||
source, (Double) call.getArgument(0)); damages.add(event); f.dispatcher.accept(event);
|
||||
if (!event.isCancelled()) targetHealth.updateAndGet(health -> Math.max(0, health - event.getFinalDamage()));
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
@@ -73,6 +83,30 @@ class ShieldBashNativeTest {
|
||||
PartyBookNativeTest.drain(f); assertEquals(1, damages.size());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(1, count(own, "ability.learned"));
|
||||
assertEquals(1, count(own, "ability.assigned"));
|
||||
assertEquals(1, count(own, "ability.cast"));
|
||||
assertEquals(1, count(own, "combat.damage"));
|
||||
assertEquals(1, count(own, "effect.applied"));
|
||||
var cast = record(own, "ability.cast"); var damage = record(own, "combat.damage");
|
||||
var knockback = record(own, "effect.applied"); var resolution = record(own, "ability.resolved");
|
||||
assertEquals(2, cast.get("slot").getAsInt()); assertTrue(cast.has("cooldown_until"));
|
||||
assertEquals(playerId.toString(), damage.get("player_id").getAsString());
|
||||
assertEquals(characterId.toString(), damage.get("character_id").getAsString());
|
||||
assertEquals(sessionId.toString(), damage.get("session_id").getAsString());
|
||||
assertEquals(targetId.toString(), damage.get("target_entity_id").getAsString());
|
||||
assertEquals(worldId.toString(), damage.getAsJsonObject("target_location").get("world_id").getAsString());
|
||||
assertEquals(5, damage.get("requested_amount").getAsDouble(), 1.0e-9);
|
||||
assertEquals(5, damage.get("actual_amount").getAsDouble(), 1.0e-9);
|
||||
assertEquals("20", damage.get("target_health_before").getAsString());
|
||||
assertEquals("15", damage.get("target_health_after").getAsString());
|
||||
assertEquals(cast.get("action_id").getAsString(), damage.get("action_id").getAsString());
|
||||
assertEquals(cast.get("action_id").getAsString(), knockback.get("action_id").getAsString());
|
||||
assertEquals("shield_bash_knockback", knockback.get("effect_id").getAsString());
|
||||
assertEquals("hit", resolution.get("outcome").getAsString());
|
||||
assertEquals("native_velocity_accepted", resolution.get("knockback_status").getAsString());
|
||||
}
|
||||
|
||||
@Test void outOfRangeOrObstructedTargetRefusesBeforeCooldownWhileStillContainingShield() throws Exception {
|
||||
@@ -93,6 +127,11 @@ class ShieldBashNativeTest {
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
|
||||
}
|
||||
var own = events(directory).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(0, count(own, "ability.cast"));
|
||||
assertEquals(0, count(own, "combat.damage"));
|
||||
assertEquals("refused", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +171,40 @@ class ShieldBashNativeTest {
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash"));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(1, count(own, "ability.cast"));
|
||||
assertEquals(0, count(own, "combat.damage"));
|
||||
assertEquals(0, count(own, "effect.applied"));
|
||||
assertEquals("cancelled", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
|
||||
@Test void zeroFinalNativeDamageNeverClaimsAHeldShieldHitOrKnockback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0));
|
||||
doReturn(true).when(a.player).hasLineOfSight(target);
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var event = new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0)); event.setDamage(0); f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
verify(target, never()).setVelocity(any(Vector.class));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(1, count(own, "ability.cast"));
|
||||
assertEquals(0, count(own, "combat.damage"));
|
||||
assertEquals(0, count(own, "effect.applied"));
|
||||
assertEquals("miss", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
|
||||
@Test void cancelledPlayerVelocityPreservesMovementAfterAcceptedDamage() throws Exception {
|
||||
@@ -157,6 +230,81 @@ class ShieldBashNativeTest {
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); verify(b.player, never()).setVelocity(any(Vector.class));
|
||||
}
|
||||
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).toList();
|
||||
assertEquals(1, damage.size(), "Owned accepted strike must not also be generic incoming player damage");
|
||||
assertEquals("shield_bash", damage.getFirst().getAsJsonObject("data").get("ability_id").getAsString());
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(0, count(own, "effect.applied"), "Cancelled velocity is not an applied knockback");
|
||||
assertEquals("native_velocity_cancelled", record(own, "ability.resolved").get("knockback_status").getAsString());
|
||||
}
|
||||
|
||||
@Test void disabledRoutineFeedbackKeepsAuditAndRejectedWriterCannotChangeAcceptedStrike() throws Exception {
|
||||
for (boolean rejectStore : new boolean[]{false, true}) {
|
||||
var directory = data.resolve(Boolean.toString(rejectStore));
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(directory);
|
||||
withShieldBash(directory, a, true); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
assertTrue(f.feedbackCommand.execute(a.player, "fantasyfeedback", new String[]{"off"})); f.tick();
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0));
|
||||
doReturn(true).when(a.player).hasLineOfSight(target);
|
||||
when(target.getVelocity()).thenReturn(new Vector());
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player, target,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0)));
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
if (rejectStore) ((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
verify(target).damage(eq(5.0), any(org.bukkit.damage.DamageSource.class));
|
||||
verify(target).setVelocity(any(Vector.class));
|
||||
assertFalse(a.messages.stream().anyMatch(message -> message.contains("Shield Bash hit")));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(directory).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(rejectStore ? 0 : 1, count(own, "combat.damage"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedNativeVelocityPublicationKeepsAcceptedDamageButNeverClaimsKnockback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0));
|
||||
doReturn(true).when(a.player).hasLineOfSight(target);
|
||||
when(target.getVelocity()).thenReturn(new Vector());
|
||||
doThrow(new IllegalStateException("simulated native velocity refusal")).when(target).setVelocity(any(Vector.class));
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0)));
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(1, count(own, "combat.damage"));
|
||||
assertEquals(0, count(own, "effect.applied"));
|
||||
assertEquals("hit", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
assertEquals("native_velocity_failed", record(own, "ability.resolved").get("knockback_status").getAsString());
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndCharacterTransferRetainCooldown() throws Exception {
|
||||
@@ -247,6 +395,38 @@ class ShieldBashNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void authorizedShieldConfigurationReadAndAcknowledgedChangeReachExportedJsonlOnlyAfterSave() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "shield-bash-base-damage"});
|
||||
assertTrue(admin.messages.getLast().contains("shield-bash-base-damage=4"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "shield-bash-base-damage", "7"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("7"), registry.find("shield_bash").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "shield-bash-base-damage", "8"});
|
||||
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed); failed.run();
|
||||
}
|
||||
admin.allowed = false;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "shield-bash-base-damage"});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")
|
||||
&& (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
|
||||
assertEquals(2, config.size());
|
||||
assertEquals("4", config.getLast().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("7", config.getLast().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void retainedSixSecondCooldownIsHonoredWithoutRewritingConfiguration() throws Exception {
|
||||
String retained = "shield-bash-cooldown-seconds: 6\n";
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), retained);
|
||||
@@ -270,6 +450,32 @@ class ShieldBashNativeTest {
|
||||
games.dmg.fantasystick.ability.ShieldBash.damage(huge));
|
||||
}
|
||||
|
||||
@Test void failedCooldownSaveNeverPublishesShieldDamageOrKnockback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0));
|
||||
doReturn(true).when(a.player).hasLineOfSight(target);
|
||||
((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters")).close();
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
verify(target, never()).setVelocity(any(Vector.class));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(0, count(own, "ability.cast"));
|
||||
assertEquals(0, count(own, "combat.damage"));
|
||||
assertEquals(0, count(own, "effect.applied"));
|
||||
assertEquals("failed", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceDealsNoDamageOrKnockback() {
|
||||
var storage=mock(CharacterStorage.class);var sessions=mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
var parties=new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(),ignored->true);
|
||||
@@ -361,6 +567,27 @@ class ShieldBashNativeTest {
|
||||
assertEquals(expected, value.get());
|
||||
}
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
|
||||
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var days = java.nio.file.Files.list(data.resolve("events"))) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
|
||||
try (var segments = java.nio.file.Files.list(day)) {
|
||||
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
private static long count(java.util.List<com.google.gson.JsonObject> own, String suffix) {
|
||||
return own.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)).count();
|
||||
}
|
||||
private static com.google.gson.JsonObject record(java.util.List<com.google.gson.JsonObject> own, String suffix) {
|
||||
return own.stream().filter(event -> event.get("type").getAsString().endsWith(suffix))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
|
||||
@@ -24,10 +24,15 @@ class StoneSkinNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedUseReducesNativeEntityDamageForFourSeconds() throws Exception {
|
||||
java.util.UUID expectedPlayer, expectedCharacter, expectedSession, expectedWorld, expectedAttacker = java.util.UUID.randomUUID();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, false);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
expectedPlayer = a.core.id; expectedCharacter = a.core.record.id(); expectedWorld = a.core.location.getWorld().getUID();
|
||||
expectedSession = ((games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"))
|
||||
.active(expectedPlayer).orElseThrow().session();
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("StoneSkin")); assertTrue(text.contains("CON14"));
|
||||
@@ -39,6 +44,9 @@ class StoneSkinNativeTest {
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
|
||||
var attacker = mock(org.bukkit.entity.Zombie.class);
|
||||
when(attacker.getUniqueId()).thenReturn(expectedAttacker);
|
||||
when(attacker.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(attacker.getLocation()).thenReturn(a.core.location.clone());
|
||||
var hit = new EntityDamageByEntityEvent(attacker, a.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(hit); assertEquals(6, hit.getDamage(), 1.0e-9);
|
||||
@@ -59,9 +67,38 @@ class StoneSkinNativeTest {
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); use(f, a);
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
|
||||
assertEquals(2, count(own, "ability.learned"));
|
||||
assertEquals(2, count(own, "ability.assigned"));
|
||||
assertEquals(1, count(own, "ability.unlearned"));
|
||||
assertEquals(1, count(own, "ability.cast"));
|
||||
assertEquals(1, count(own, "effect.applied"));
|
||||
assertEquals(1, count(own, "effect.ended"));
|
||||
assertEquals(1, count(own, "combat.prevented"));
|
||||
var cast = record(own, "ability.cast"); var applied = record(own, "effect.applied");
|
||||
var prevented = record(own, "combat.prevented"); var ended = record(own, "effect.ended");
|
||||
assertEquals(2, cast.get("slot").getAsInt());
|
||||
assertTrue(cast.has("cooldown_until"));
|
||||
assertEquals(expectedPlayer.toString(), prevented.get("target_player_id").getAsString());
|
||||
assertEquals(expectedCharacter.toString(), prevented.get("target_character_id").getAsString());
|
||||
assertEquals(expectedSession.toString(), prevented.get("target_session_id").getAsString());
|
||||
assertEquals(expectedWorld.toString(), prevented.getAsJsonObject("target_location").get("world_id").getAsString());
|
||||
assertEquals(expectedAttacker.toString(), prevented.get("attacker_entity_id").getAsString());
|
||||
assertEquals(expectedWorld.toString(), prevented.getAsJsonObject("attacker_location").get("world_id").getAsString());
|
||||
assertEquals("25", applied.get("reduction_percent").getAsString());
|
||||
assertTrue(applied.has("effect_until"));
|
||||
assertEquals("8", prevented.get("incoming_amount").getAsString());
|
||||
assertEquals("6", prevented.get("remaining_amount").getAsString());
|
||||
assertEquals("2", prevented.get("actual_amount").getAsString());
|
||||
assertEquals(cast.get("action_id").getAsString(), prevented.get("action_id").getAsString());
|
||||
assertEquals(applied.get("application_id").getAsString(), ended.get("application_id").getAsString());
|
||||
assertEquals("expired", ended.get("reason").getAsString());
|
||||
assertEquals("expired", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
|
||||
@Test void environmentalAndCancelledDamageRemainUntouchedWhileProjectileCombatIsReduced() throws Exception {
|
||||
var arrowId = java.util.UUID.randomUUID();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
|
||||
@@ -71,18 +108,26 @@ class StoneSkinNativeTest {
|
||||
mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(fall); assertEquals(8, fall.getDamage(), 1.0e-9);
|
||||
var arrow = mock(org.bukkit.entity.Arrow.class);
|
||||
when(arrow.getUniqueId()).thenReturn(arrowId);
|
||||
when(arrow.getType()).thenReturn(org.bukkit.entity.EntityType.ARROW);
|
||||
when(arrow.getLocation()).thenReturn(a.core.location.clone());
|
||||
var projectile = new EntityDamageByEntityEvent(arrow, a.player,
|
||||
EntityDamageEvent.DamageCause.PROJECTILE, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(projectile); assertEquals(6, projectile.getDamage(), 1.0e-9);
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.LOWEST)
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void veto(EntityDamageByEntityEvent event) { event.setCancelled(true); }
|
||||
}
|
||||
f.dispatcher.register(new Veto(), f.plugin);
|
||||
var cancelled = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), a.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled()); assertEquals(8, cancelled.getDamage(), 1.0e-9);
|
||||
f.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled());
|
||||
assertEquals(6, cancelled.getDamage(), 1.0e-9, "Late cancellation wins even after reduction was proposed");
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
|
||||
assertEquals(1, count(own, "combat.prevented"), "Fall and cancelled native damage cannot claim prevention");
|
||||
assertEquals(arrowId.toString(), record(own, "combat.prevented").get("attacker_entity_id").getAsString());
|
||||
}
|
||||
|
||||
@Test void replacementExpirySessionExitAndTransferRemoveOnlyOwnedReductionAndRetainDeadline() throws Exception {
|
||||
@@ -109,6 +154,63 @@ class StoneSkinNativeTest {
|
||||
f.players.put(b.core.id, b.player); f.activate(b);
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
|
||||
assertEquals(3, count(own, "effect.applied"));
|
||||
assertEquals(3, count(own, "effect.ended"));
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("replaced")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("expired")).count());
|
||||
}
|
||||
|
||||
@Test void laterNativeDamageOverrideCannotClaimAnUnusedReduction() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
class Override implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void restore(EntityDamageByEntityEvent event) { event.setDamage(8); }
|
||||
}
|
||||
f.dispatcher.register(new Override(), f.plugin);
|
||||
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
|
||||
assertEquals(1, count(own, "effect.applied"));
|
||||
assertEquals(0, count(own, "combat.prevented"));
|
||||
}
|
||||
|
||||
@Test void feedbackOffPreservesPreventionAuditAndRejectedEventStorePreservesNativeReduction() throws Exception {
|
||||
for (boolean rejectStore : new boolean[]{false, true}) {
|
||||
var directory = data.resolve(Boolean.toString(rejectStore));
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(directory); withStoneSkin(directory, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
assertTrue(f.feedbackCommand.execute(a.player, "fantasyfeedback", new String[]{"off"}));
|
||||
f.tick();
|
||||
if (rejectStore) ((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var attacker = mock(org.bukkit.entity.Zombie.class);
|
||||
when(attacker.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(attacker.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(attacker.getLocation()).thenReturn(a.core.location.clone());
|
||||
var hit = new EntityDamageByEntityEvent(attacker, a.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(hit);
|
||||
assertEquals(6, hit.getDamage(), 1.0e-9);
|
||||
assertFalse(a.messages.stream().anyMatch(message -> message.contains("prevented")));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(directory).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
|
||||
assertEquals(rejectStore ? 0 : 1, count(own, "combat.prevented"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedConfigurationAffectsFutureCastsWhileActiveProtectionKeepsSnapshot() throws Exception {
|
||||
@@ -158,6 +260,44 @@ class StoneSkinNativeTest {
|
||||
stone.close(); mitigation.close();
|
||||
}
|
||||
|
||||
@Test void rejectedCooldownSaveCannotEmitAStoneEffectOrClaimPrevention() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters")).close();
|
||||
use(f, a); var rejected = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(rejected); rejected.run();
|
||||
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
|
||||
assertEquals(0, count(own, "ability.cast"));
|
||||
assertEquals(0, count(own, "effect.applied"));
|
||||
assertEquals(0, count(own, "combat.prevented"));
|
||||
assertEquals("failed", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
|
||||
@Test void failingPreventionFeedbackCannotInterruptAcceptedNativeDamageObservation() {
|
||||
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
when(execution.stillCurrent()).thenReturn(true);
|
||||
doThrow(new IllegalStateException("broken feedback")).when(execution).report(any(), any(), any());
|
||||
var observed = new java.util.ArrayList<games.dmg.fantasystick.ability.CombatMitigation.Accepted>();
|
||||
mitigation.grantAccepted(execution, player, new java.math.BigDecimal("25"), () -> true, observed::add);
|
||||
var hit = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
mitigation.reduce(hit);
|
||||
assertDoesNotThrow(() -> mitigation.accepted(hit));
|
||||
assertEquals(6, hit.getDamage(), 1.0e-9);
|
||||
assertEquals(1, observed.size());
|
||||
assertEquals(2, observed.getFirst().prevented(), 1.0e-9);
|
||||
mitigation.close();
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceCreatesNoReduction() {
|
||||
var storage = mock(CharacterStorage.class);
|
||||
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
@@ -192,6 +332,38 @@ class StoneSkinNativeTest {
|
||||
engine.close(); stone.close(); mitigation.close(); parties.close();
|
||||
}
|
||||
|
||||
@Test void authorizedStoneConfigurationReadAndAcknowledgedChangeReachJsonlOnlyAfterSave() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "stone-skin-damage-reduction-percent"});
|
||||
assertTrue(admin.messages.getLast().contains("stone-skin-damage-reduction-percent=25"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "stone-skin-damage-reduction-percent", "40"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(0, new java.math.BigDecimal("40").compareTo(
|
||||
registry.find("stone_skin").orElseThrow().parameters().get("damage-reduction-percent")));
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "stone-skin-damage-reduction-percent", "50"});
|
||||
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed); failed.run();
|
||||
}
|
||||
admin.allowed = false;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "stone-skin-damage-reduction-percent"});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")
|
||||
&& (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
|
||||
assertEquals(2, config.size());
|
||||
assertEquals("25", config.getLast().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("40", config.getLast().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRoutesAndStartupValidationCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"stone-skin-cooldown-seconds: 2\nstone-skin-duration-seconds: 6\nstone-skin-damage-reduction-percent: 40\n");
|
||||
@@ -237,6 +409,29 @@ class StoneSkinNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
|
||||
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var days = java.nio.file.Files.list(data.resolve("events"))) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
|
||||
try (var segments = java.nio.file.Files.list(day)) {
|
||||
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static long count(java.util.List<com.google.gson.JsonObject> own, String suffix) {
|
||||
return own.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)).count();
|
||||
}
|
||||
|
||||
private static com.google.gson.JsonObject record(java.util.List<com.google.gson.JsonObject> own, String suffix) {
|
||||
return own.stream().filter(event -> event.get("type").getAsString().endsWith(suffix))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
}
|
||||
|
||||
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor target, double amount) {
|
||||
var event = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), target.player,
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
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 games.dmg.fantasystick.persistence.SwitchDelaySettings;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Registered administrator policy changes must be independently visible in exported day segments. */
|
||||
class SwitchDelayAdminEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredReadAndAcknowledgedZeroThenNonzeroChangesReachExportedJsonlAndActualExits() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
|
||||
"config", SwitchDelaySettings.KEY});
|
||||
assertTrue(admin.messages.getLast().contains("=900"));
|
||||
var player = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(player.core.id, player.player); f.activate(player);
|
||||
change(f, admin, "0");
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
sessions.exit(player.player); f.awaitCharacterAndReceipt(player);
|
||||
var first = f.receipts().find(player.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
assertEquals(Duration.ZERO, Duration.between(first.exitedAt(), first.deadline()));
|
||||
f.reenter(player);
|
||||
change(f, admin, "37");
|
||||
sessions.exit(player.player); f.awaitCharacterAndReceipt(player);
|
||||
var second = f.receipts().find(player.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
assertEquals(Duration.ofSeconds(37), Duration.between(second.exitedAt(), second.deadline()));
|
||||
assertEquals(first.exitedAt(), first.deadline(), "Changing policy did not rewrite an existing deadline");
|
||||
for (int i = 0; i < 200 && config().size() < 3; i++) Thread.sleep(10);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var entries = config();
|
||||
assertEquals(List.of("games.dmg.minecraft.fantasystick.config.read",
|
||||
"games.dmg.minecraft.fantasystick.config.changed",
|
||||
"games.dmg.minecraft.fantasystick.config.changed"), entries.stream()
|
||||
.map(event -> event.get("type").getAsString()).toList());
|
||||
assertEquals(List.of("900", "0", "37"), entries.stream()
|
||||
.map(event -> event.getAsJsonObject("data").get("effective").getAsString()).toList());
|
||||
assertEquals(List.of("900", "0"), entries.subList(1, 3).stream()
|
||||
.map(event -> event.getAsJsonObject("data").get("before").getAsString()).toList());
|
||||
assertTrue(entries.get(1).getAsJsonObject("data").get("zero_disabled").getAsBoolean());
|
||||
assertFalse(entries.get(2).getAsJsonObject("data").get("zero_disabled").getAsBoolean());
|
||||
}
|
||||
|
||||
@Test void deniedInvalidAndFailedWritesNeverClaimPolicyChange() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender();
|
||||
admin.allowed = false;
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY});
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "0"});
|
||||
admin.allowed = true;
|
||||
for (var invalid : List.of("-1", "1.5", "not-a-number")) {
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, invalid});
|
||||
assertTrue(admin.messages.getLast().contains("invalid"));
|
||||
}
|
||||
Files.createDirectory(data.resolve("config.yml"));
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "0"});
|
||||
for (int i = 0; i < 20 && !admin.messages.getLast().contains("save failed"); i++) {
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
}
|
||||
assertTrue(admin.messages.getLast().contains("save failed"));
|
||||
var settings = (SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "switchDelaySettings");
|
||||
assertEquals("900", settings.value());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(config().isEmpty(), "A rejected request, read or save has no domain success");
|
||||
}
|
||||
|
||||
@Test void concurrentAcknowledgedWritesReportEffectiveBeforeAndAfterWithoutSupersededSuccess() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "0"});
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "37"});
|
||||
for (int i = 0; i < 30 && admin.messages.size() < 2; i++) {
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
}
|
||||
assertEquals(2, admin.messages.size(), admin.messages.toString());
|
||||
assertTrue(admin.messages.stream().allMatch(message -> message.startsWith("Saved ")));
|
||||
for (int i = 0; i < 200 && config().size() < 2; i++) Thread.sleep(10);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var changes = config(); assertEquals(2, changes.size());
|
||||
assertEquals(List.of("900", "0"), changes.stream()
|
||||
.map(event -> event.getAsJsonObject("data").get("before").getAsString()).toList());
|
||||
assertEquals(List.of("0", "37"), changes.stream()
|
||||
.map(event -> event.getAsJsonObject("data").get("effective").getAsString()).toList());
|
||||
assertEquals(2, changes.stream().map(event -> event.getAsJsonObject("data")
|
||||
.get("operation_id").getAsString()).distinct().count());
|
||||
}
|
||||
|
||||
@Test void supersededSavedCallbackCannotExportASecondOrRollbackEffectiveChange() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
for (int i = 0; i < 30 && !f.tasks.isEmpty(); i++) f.tasks.remove().run();
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "0"});
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "37"});
|
||||
var settings = (SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
f.plugin, "switchDelaySettings");
|
||||
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
settings, "worker");
|
||||
java.util.concurrent.CompletableFuture.runAsync(() -> {}, worker).get(5, TimeUnit.SECONDS);
|
||||
var first = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(first);
|
||||
var second = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(second);
|
||||
second.run(); first.run();
|
||||
assertEquals("37", settings.value());
|
||||
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var changes = config();
|
||||
assertEquals(1, changes.size(), "The stale callback must not claim a policy publication");
|
||||
assertEquals("900", changes.getFirst().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("37", changes.getFirst().getAsJsonObject("data").get("effective").getAsString());
|
||||
}
|
||||
|
||||
@Test void playerAdministratorReadCapturesExactIdentityAndLocationWithoutSaving() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
|
||||
doReturn("Administrator").when(admin.player).getName();
|
||||
f.players.put(admin.core.id, admin.player);
|
||||
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY});
|
||||
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
|
||||
f.plugin.setEnabled(false);
|
||||
var entries = config(); assertEquals(1, entries.size());
|
||||
var read = entries.getFirst(); var values = read.getAsJsonObject("data");
|
||||
assertTrue(read.get("type").getAsString().endsWith("config.read"));
|
||||
assertEquals(admin.core.id.toString(), values.get("player_id").getAsString());
|
||||
assertEquals("player", values.get("actor_kind").getAsString());
|
||||
assertEquals("future_exit", values.get("application_scope").getAsString());
|
||||
assertEquals("900", values.get("default").getAsString());
|
||||
assertEquals("900", values.get("effective").getAsString());
|
||||
assertEquals(admin.core.location.getX(), values.getAsJsonObject("location").get("x").getAsDouble());
|
||||
}
|
||||
assertFalse(Files.exists(data.resolve("config.yml")), "A read does not persist configuration");
|
||||
}
|
||||
|
||||
@Test void failedIntentHandoffCannotUndoSavedPolicyAndReplaysStableOperationAfterRestart() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var log = (games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
|
||||
log.close();
|
||||
change(f, admin, "0");
|
||||
assertEquals("0", ((SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
f.plugin, "switchDelaySettings")).value());
|
||||
f.plugin.setEnabled(false);
|
||||
assertTrue(config().isEmpty(), "No pending file counts as an exported event");
|
||||
f.plugin.setEnabled(true);
|
||||
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
|
||||
assertEquals("0", ((SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
f.plugin, "switchDelaySettings")).value());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertEquals(1, config().size(), "One saved operation replays without another configuration request");
|
||||
assertEquals("0", config().getFirst().getAsJsonObject("data").get("effective").getAsString());
|
||||
}
|
||||
|
||||
@Test void auditIntentFailureCannotRollBackPolicyOrPreventActualZeroDurationExit() throws Exception {
|
||||
Files.writeString(data.resolve("switch-config-event-intents"), "blocked");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender();
|
||||
change(f, admin, "0");
|
||||
var player = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(player.core.id, player.player); f.activate(player);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
sessions.exit(player.player); f.awaitCharacterAndReceipt(player);
|
||||
var exit = f.receipts().find(player.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
assertEquals(Duration.ZERO, Duration.between(exit.exitedAt(), exit.deadline()));
|
||||
assertEquals("0", ((SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
f.plugin, "switchDelaySettings")).value());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(config().isEmpty(), "Audit-store failure cannot invent an exported change");
|
||||
}
|
||||
|
||||
@Test void closingBeforeAcknowledgedCallbackNeverLogsUnpublishedRuntimeChange() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var settings = (SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
f.plugin, "switchDelaySettings");
|
||||
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
settings, "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
worker.execute(() -> {
|
||||
entered.countDown();
|
||||
try { release.await(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "0"});
|
||||
try { f.plugin.setEnabled(false); } finally { release.countDown(); }
|
||||
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
while (!f.tasks.isEmpty()) f.tasks.remove().run();
|
||||
assertEquals("900", settings.value(), "Closed command cannot publish a late setting");
|
||||
assertTrue(config().isEmpty(), "Persisted but unpublished work is not an acknowledged domain change");
|
||||
f.plugin.setEnabled(true);
|
||||
assertEquals("0", ((SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
f.plugin, "switchDelaySettings")).value(), "The later boot loads the committed setting");
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(config().isEmpty(), "Startup load does not invent a prior runtime config.changed");
|
||||
}
|
||||
|
||||
private void change(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
EquipmentAdminCommandNativeTest.Sender admin, String value) throws Exception {
|
||||
int messages = admin.messages.size();
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, value});
|
||||
for (int i = 0; i < 30 && admin.messages.size() == messages; i++) {
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(callback, "Acknowledged setting needs a native callback");
|
||||
callback.run();
|
||||
}
|
||||
assertTrue(admin.messages.getLast().startsWith("Saved "), admin.messages.toString());
|
||||
}
|
||||
|
||||
private List<JsonObject> config() throws Exception {
|
||||
var entries = new ArrayList<JsonObject>();
|
||||
var root = data.resolve("events");
|
||||
if (!Files.isDirectory(root)) return entries;
|
||||
try (var days = Files.list(root)) {
|
||||
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(file -> file.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()) {
|
||||
var event = JsonParser.parseString(line).getAsJsonObject();
|
||||
if (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed")) {
|
||||
var values = event.getAsJsonObject("data");
|
||||
if (values.has("key") && values.get("key").getAsString().equals(SwitchDelaySettings.KEY))
|
||||
entries.add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,15 @@ class SwitchDelayEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void exportedRecordsIgnoreTransientPendingFiles() throws Exception {
|
||||
var day = data.resolve("events/2026-09-26");
|
||||
var pending = data.resolve("events/pending");
|
||||
Files.createDirectories(day); Files.createDirectories(pending);
|
||||
Files.writeString(day.resolve("0.jsonl"), "{\"type\":\"exported\"}\n");
|
||||
Files.writeString(pending.resolve("transient.jsonl"), "{\"type\":\"not_exported\"}\n");
|
||||
assertEquals(List.of("exported"), records().stream().map(event -> event.get("type").getAsString()).toList());
|
||||
}
|
||||
|
||||
@Test void persistedExitAndPrepackSameOrDifferentCharacterDecisionsReachJsonl() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
@@ -147,10 +156,22 @@ class SwitchDelayEventsNativeTest {
|
||||
|
||||
private List<com.google.gson.JsonObject> records() throws Exception {
|
||||
var events = new ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = Files.walk(data.resolve("events"))) {
|
||||
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : Files.readAllLines(path))
|
||||
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
var directory = data.resolve("events");
|
||||
if (!Files.exists(directory)) return events;
|
||||
// Pending intents are transient and may be moved while a live writer is draining them.
|
||||
try (var days = Files.list(directory)) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
|
||||
&& Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
|
||||
try (var segments = Files.list(day)) {
|
||||
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
var content = Files.readString(path);
|
||||
int end = content.lastIndexOf('\n');
|
||||
if (end < 0) continue;
|
||||
for (var line : content.substring(0, end).lines().toList())
|
||||
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user