Compare commits

...
25 Commits
Author SHA1 Message Date
dmg 439c1b9cdd feat(issuance): export acknowledged admin sheet events
Release / release (push) Successful in 18m0s
2026-09-26 12:25:24 -04:00
dmg 2ae8f1ddf9 test(fishing): await acknowledged catch feedback
Release / release (push) Successful in 18m0s
2026-09-26 10:38:33 -04:00
dmg 912c077da1 fix(recovery): preserve pulses when audit world unloads 2026-09-26 10:38:33 -04:00
dmg 9b378b4237 feat(inspire): export accepted native absorption events
Release / release (push) Failing after 9m42s
2026-09-26 08:57:01 -04:00
dmg 93897ce516 feat(hex): export accepted native prevention events
Release / release (push) Successful in 17m49s
2026-09-26 07:40:13 -04:00
dmg fc49ee1cfb feat(shield-bash): audit accepted strike and knockback
Release / release (push) Successful in 16m56s
2026-09-25 23:32:35 -04:00
dmg ee7d9036a2 feat(stone-skin): audit owned damage mitigation
Release / release (push) Failing after 16m11s
2026-09-25 23:13:37 -04:00
dmg 4616ee80e3 feat(second-wind): audit native self-heal and config
Release / release (push) Failing after 17m6s
2026-09-25 23:00:17 -04:00
dmg e6b66d9e34 feat(quickstep): audit owned dash outcomes
Release / release (push) Failing after 8m47s
2026-09-25 22:40:16 -04:00
dmg fc90af3a62 feat(longbow): audit native draw and arrow damage
Release / release (push) Successful in 17m13s
2026-09-25 22:10:24 -04:00
dmg f6d95ca338 feat(frost-shard): audit owned arrow and slow outcomes
Release / release (push) Successful in 17m59s
2026-09-25 21:52:30 -04:00
dmg db4c3b2842 feat(great-sword): audit charged native strikes
Release / release (push) Successful in 16m41s
2026-09-25 21:22:45 -04:00
dmg 830049b339 feat(nutrition): audit native healing and restoration
Release / release (push) Successful in 17m20s
2026-09-25 20:52:29 -04:00
dmg fcbc2814c2 fix(ci): avoid transient event files in native test scan
Release / release (push) Successful in 14m0s
2026-09-25 17:06:08 -04:00
dmg 897a0606ba fix(adventure): retract owned hook after quest catch
Release / release (push) Failing after 13m17s
2026-09-25 16:39:35 -04:00
dmg c13c3093cf test(combat): await proved basic attack reentry
Release / release (push) Successful in 14m58s
2026-09-25 15:57:40 -04:00
dmg bb8d1857b4 feat(combat): audit accepted basic attacks
Release / release (push) Failing after 7m39s
2026-09-25 15:26:45 -04:00
dmg c7fce1a53d fix(adventure): retain quest hook while fish approaches
Release / release (push) Successful in 8m46s
2026-09-25 15:19:11 -04:00
dmg af7c75b6ed feat(parties): audit native protection decisions
Release / release (push) Successful in 15m37s
2026-09-25 14:11:48 -04:00
dmg 39881ad6fc feat(recovery): audit session and configuration outcomes
Release / release (push) Successful in 15m50s
2026-09-25 13:36:58 -04:00
dmg 9737ed0ddb feat(equipment): audit native packing and recovery
Release / release (push) Successful in 15m47s
2026-09-25 12:53:32 -04:00
dmg 53366b3c50 feat(characters): audit craft rename and observed transfer
Release / release (push) Successful in 14m59s
2026-09-25 12:07:48 -04:00
dmg 84205c0c14 feat(sessions): audit persisted character-switch deadlines
Release / release (push) Failing after 7m33s
2026-09-25 11:29:18 -04:00
dmg 2cc053e6ff feat(events): audit bound book volume navigation
Release / release (push) Failing after 7m17s
2026-09-25 10:44:46 -04:00
dmg 3f104cb0e2 feat(events): record native recipe discovery
Release / release (push) Successful in 14m50s
2026-09-25 10:07:18 -04:00
78 changed files with 9921 additions and 359 deletions

No files matched your search

@@ -124,6 +124,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
private games.dmg.fantasystick.ability.GreatSword greatSword;
private games.dmg.fantasystick.persistence.GreatSwordSettings greatSwordSettings;
private games.dmg.fantasystick.ability.Revive revive;
private games.dmg.fantasystick.persistence.RevivalStore revivals;
private games.dmg.fantasystick.session.AdminRevival adminRevival;
@@ -177,9 +178,25 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private games.dmg.fantasystick.events.SessionEventBridge sessionAuditEvents;
private games.dmg.fantasystick.events.NutritionEventBridge nutritionAuditEvents;
private java.util.concurrent.CompletionStage<Void> nutritionAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchAuditEvents;
private java.util.concurrent.CompletionStage<Void> switchAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityAuditEvents;
private java.util.concurrent.CompletionStage<Void> identityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentAuditEvents;
private java.util.concurrent.CompletionStage<Void> equipmentAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SessionRecoveryEventBridge sessionRecoveryAuditEvents;
private java.util.concurrent.CompletionStage<Void> sessionRecoveryAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
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();
@@ -245,7 +262,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var rewardRules = combatRewardRules(hardMobXp);
var fireballConfiguration = fireballConfiguration();
var fireballInitial = games.dmg.fantasystick.persistence.FireballSettings.parse(fireballConfiguration);
var greatSwordSettings = greatSwordSettings();
var greatSwordConfiguration = greatSwordConfiguration();
var greatSwordInitial = games.dmg.fantasystick.persistence.GreatSwordSettings.parse(greatSwordConfiguration);
var frostShardConfiguration = frostShardConfiguration();
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
var longbowConfiguration = longbowConfiguration();
@@ -433,7 +451,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
secondWindSettings = new games.dmg.fantasystick.persistence.SecondWindSettings(
getDataFolder().toPath(), secondWindConfiguration);
secondWind = new games.dmg.fantasystick.ability.SecondWind(secondWindInitial, characters, sessions, nextTick,
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read);
this::dispatchOwnedHealing, games.dmg.fantasystick.session.NativeHealthLimit::read);
stoneSkinSettings = new games.dmg.fantasystick.persistence.StoneSkinSettings(
getDataFolder().toPath(), stoneSkinConfiguration);
combatMitigation = new games.dmg.fantasystick.ability.CombatMitigation();
@@ -454,7 +472,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
mendingLightSettings = new games.dmg.fantasystick.persistence.MendingLightSettings(
getDataFolder().toPath(), mendingLightConfiguration);
mendingLight = new games.dmg.fantasystick.ability.MendingLight(mendingLightInitial, characters, sessions, parties,
nextTick, event -> getServer().getPluginManager().callEvent(event),
nextTick, this::dispatchOwnedHealing,
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
purifySettings = new games.dmg.fantasystick.persistence.PurifySettings(getDataFolder().toPath(), purifyConfiguration);
@@ -480,7 +498,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
sanctuarySettings = new games.dmg.fantasystick.persistence.SanctuarySettings(
getDataFolder().toPath(), sanctuaryConfiguration);
sanctuary = new games.dmg.fantasystick.ability.Sanctuary(sanctuaryInitial, characters, sessions, parties,
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
combatMitigation, nextTick, this::dispatchOwnedHealing,
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()),
particleDelivery);
@@ -490,7 +508,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
warBannerInitial, sessions, parties, outgoingDamageBonus, particleDelivery);
meteorSettings = new games.dmg.fantasystick.persistence.MeteorSettings(getDataFolder().toPath(), meteorConfiguration);
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties, particleDelivery);
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
greatSwordSettings = new games.dmg.fantasystick.persistence.GreatSwordSettings(
getDataFolder().toPath(), greatSwordConfiguration);
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordInitial);
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
(size, title) -> getServer().createInventory(null, size, title),
@@ -520,11 +540,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
var gearItems=new games.dmg.fantasystick.items.GearItems(this);sessions.attachGear(gearItems);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions, nextTick);
sessions.attachNutritionPolicy(nutritionPolicy);
outOfCombatRecoverySettings = new games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings(
getDataFolder().toPath(), recoveryConfiguration);
outOfCombatRecovery = new games.dmg.fantasystick.session.OutOfCombatRecovery(recoveryInitial, characters, sessions,
Clock.systemUTC(), nextTick, event -> getServer().getPluginManager().callEvent(event),
Clock.systemUTC(), nextTick, this::dispatchOwnedHealing,
games.dmg.fantasystick.session.NativeHealthLimit::read,
problem -> getLogger().log(Level.WARNING,
"Out-of-combat recovery failed for character " + problem.character(), problem.cause()), combatFeedback);
@@ -537,7 +558,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
healthStealSettings = new games.dmg.fantasystick.persistence.HealthStealSettings(
getDataFolder().toPath(), healthStealInitial.healing().toString());
healthSteal = new games.dmg.fantasystick.passive.HealthSteal(healthStealInitial, characters, sessions, nextTick,
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read,
this::dispatchOwnedHealing, games.dmg.fantasystick.session.NativeHealthLimit::read,
problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()),
combatFeedback);
speedI = new games.dmg.fantasystick.passive.SpeedI(sessions);
@@ -610,7 +631,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
rareShopMenus=new games.dmg.fantasystick.shop.RareShopMenus(this,rareShop);var localRareMenus=rareShopMenus;
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;if(rareEvents!=null)rareEvents.catalogRefreshed(catalog);try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
rareMerchantController=new games.dmg.fantasystick.shop.RareMerchantController(this,rareMerchants,rareShopMenus::open);
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,event->getServer().getPluginManager().callEvent(event),nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,this::dispatchOwnedHealing,nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
rareReskills=new games.dmg.fantasystick.shop.RareReskillService(rareInventory,characters,sessions,nextTick,record->{for(String ability:record.learned())abilityEngine.remove(record.id(),ability);speedI.sweep();lowLightVision.sweep();},failure->getLogger().log(Level.WARNING,"Rare Reskill use failed",failure));
rareMagicCores=new games.dmg.fantasystick.shop.RareMagicCoreService(rareInventory,characters,sessions,nextTick,failure->getLogger().log(Level.WARNING,"Rare Magic Core use failed",failure));
rarePotions.install();rareReskills.install();rareMagicCores.install();books.attachRareInventory(rareInventory,rarePotions,rareReskills,rareMagicCores);
@@ -652,6 +673,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
inspireSettings, inspire, abilityRegistry);
admin.attachFireball(fireballSettings, fireball);
admin.attachGreatSword(greatSwordSettings, greatSword);
admin.attachMendingLight(mendingLightSettings, mendingLight);
admin.attachPurify(purifySettings, purify);
admin.attachGuardianBond(guardianBondSettings, guardianBond);
@@ -719,10 +741,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);
@@ -730,6 +759,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");
@@ -744,9 +782,38 @@ public final class FantasyStickPlugin extends JavaPlugin {
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);
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);
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(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Equipment event intent failed", failure));
sessions.attachEquipmentEvents(equipmentAuditEvents);
sessionRecoveryAuditEvents = new games.dmg.fantasystick.events.SessionRecoveryEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Session recovery event intent failed", failure));
admin.attachSessionRecoveryEvents(sessionRecoveryAuditEvents);
sessions.attachSessionRecoveryEvents(sessionRecoveryAuditEvents);
sessions.attachDownedEvents(downedEvents);
downedController.attachEvents(downedEvents);
revive.attachDownedEvents(downedEvents);
@@ -756,7 +823,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; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; messageAuditEvents = null;
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -831,6 +898,39 @@ public final class FantasyStickPlugin extends JavaPlugin {
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
recipeDiscovery = new RecipeDiscovery(CharacterSheets.RECIPE, getLogger());
if (eventLog != null) {
var recipeEvents = new games.dmg.fantasystick.events.RecipeDiscoveryEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Recipe discovery event intent failed", failure));
recipeDiscovery.attachEvents(recipeEvents);
eventLog.ready(); // Queue startup recovery before any recipe event handoff.
var replayNutritionEvents = nutritionAuditEvents;
nutritionAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous nutrition event intent drain failed", failure);
if (replayNutritionEvents != null) replayNutritionEvents.replay();
});
var replaySwitchEvents = switchAuditEvents;
switchAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous switch event intent drain failed", failure);
if (replaySwitchEvents != null) replaySwitchEvents.replay();
});
var replayIdentityEvents = identityAuditEvents;
identityAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous identity event intent drain failed", failure);
if (replayIdentityEvents != null) replayIdentityEvents.replay();
});
var replayEquipmentEvents = equipmentAuditEvents;
equipmentAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous equipment event intent drain failed", failure);
if (replayEquipmentEvents != null) replayEquipmentEvents.replay();
});
var replaySessionRecoveryEvents = sessionRecoveryAuditEvents;
sessionRecoveryAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous recovery event intent drain failed", failure);
if (replaySessionRecoveryEvents != null) replaySessionRecoveryEvents.replay();
});
recipeEvents.replay(); // Serial worker replays old intents before new discoveries are captured.
}
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
var onlinePlayers = List.copyOf(getServer().getOnlinePlayers());
recipeDiscovery.discoverAll(onlinePlayers);
@@ -867,6 +967,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 closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = 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;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
@@ -908,6 +1013,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;
@@ -982,6 +1088,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);
@@ -1068,6 +1175,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)
@@ -1170,6 +1279,45 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingEvents != null) cleanup("session events", closingEvents::close);
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
if (closingSessions != null) cleanup("equipment sessions", closingSessions::close);
if (closingRecipeDiscovery != null) cleanup("recipe discovery event intents", closingRecipeDiscovery::close);
if (closingNutritionAuditEvents != null) cleanup("nutrition event intent drain", () -> {
var sessionsDrained = closingSessions == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
nutritionAuditDrain = sessionsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Nutrition owner drain failed", failure);
closingNutritionAuditEvents.close();
return null;
}).thenCompose(ignored -> closingNutritionAuditEvents.drained());
});
if (closingSwitchAuditEvents != null) cleanup("switch event intent drain", () -> {
var sessionsDrained = closingSessions == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
switchAuditDrain = sessionsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Switch exit drain failed", failure);
closingSwitchAuditEvents.close();
return null;
}).thenCompose(ignored -> closingSwitchAuditEvents.drained());
});
if (closingEquipmentAuditEvents != null) cleanup("equipment event intent drain", () -> {
var sessionsDrained = closingSessions == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
equipmentAuditDrain = sessionsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Equipment session drain failed", failure);
closingEquipmentAuditEvents.close();
return null;
}).thenCompose(ignored -> closingEquipmentAuditEvents.drained());
});
if (closingSessionRecoveryAuditEvents != null) cleanup("recovery event intent drain", () -> {
var sessionsDrained = closingSessions == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
sessionRecoveryAuditDrain = java.util.concurrent.CompletableFuture.allOf(
sessionsDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Recovery owner drain failed", failure);
closingSessionRecoveryAuditEvents.close();
return null;
}).thenCompose(ignored -> closingSessionRecoveryAuditEvents.drained());
});
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
unregister(closingModes);
@@ -1215,6 +1363,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 (closingIdentityAuditEvents != null) cleanup("character identity event intent drain", () -> {
var charactersDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
identityAuditDrain = charactersDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character save drain failed", failure);
closingIdentityAuditEvents.close();
return null;
}).thenCompose(ignored -> closingIdentityAuditEvents.drained());
});
if (closingRevivals != null) cleanup("revival storage", closingRevivals::close);
// Never await IO on the server thread or close receipts ahead of accepted preparations.
if (closingReceipts != null) cleanup("equipment receipt drain", () -> {
@@ -1446,15 +1603,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&&divided[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());
}
@@ -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)&&current>=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);}
@@ -61,7 +61,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})\"");
private static final java.util.regex.Pattern RARE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9-]{0,63})\"");
private static final java.util.regex.Pattern RARE_CONFIRM_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36})\"");
private record View(String nonce, ControlInput input, int previous, int next) { }
private record View(String nonce, ControlInput input, int start, int navigationPage,
int previous, int next) { }
private record AdminView(String nonce, UUID administrator, org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active,
int previous, int next) { }
@@ -244,6 +245,10 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
private void load(ControlInput input, int offset, boolean userRequested) {
load(input, offset, userRequested, null);
}
private void load(ControlInput input, int offset, boolean userRequested, View from) {
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|| !input.stillCurrent()) return;
var owner = input.session().player();
@@ -255,7 +260,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
rareViews.remove(owner);
reskillViews.remove(owner);
var current=sessions.currentRecord(input.session()).orElse(null);
var audit=userRequested&&bookEvents!=null&&current!=null?bookEvents.request(input,current,offset):null;
var navigation=from==null?null:new games.dmg.fantasystick.events.BookEventBridge.Navigation(
from.start(),from.navigationPage(),offset);
var audit=userRequested&&bookEvents!=null&&current!=null?bookEvents.request(input,current,offset,navigation):null;
var superseded=pendingBookEvents.remove(owner);if(superseded!=null)bookEvents.refused(superseded,"superseded");
if(audit!=null)pendingBookEvents.put(owner,audit);
Object ticket = new Object();
@@ -398,8 +405,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (allOverview.size() > VOLUME_SIZE) {
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + allOverview.size());
if (interactive) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < allOverview.size() ? end : -1);
var view = new View(UUID.randomUUID().toString(), input, start, pages.size() + 1,
start > 0 ? start - VOLUME_SIZE : -1, end < allOverview.size() ? end : -1);
views.put(input.session().player(), view);
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
.append(pageButton("Previous pages", view, 0));
@@ -775,7 +782,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
int offset = match.group(2).equals("0") ? view.previous() : view.next();
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) { refusePage(player,"stale_or_unavailable_page"); return; }
// Reread authoritative data, rather than retaining a stale giant character snapshot.
load(view.input(), offset, true);
load(view.input(), offset, true, view);
}
private void refusePage(org.bukkit.entity.Player player,String reason){
@@ -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());
}
@@ -17,6 +17,12 @@ public final class CharacterAnvilNames implements Listener {
private final Consumer<Failure> failures;
private final org.bukkit.inventory.ItemFactory items;
private final Consumer<games.dmg.fantasystick.persistence.CharacterRecord> acknowledged;
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
public void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
if (identityEvents != null) throw new IllegalStateException("Anvil identity events already attached");
identityEvents = java.util.Objects.requireNonNull(events);
}
/** Diagnostics may run on the persistence worker and must not access native player state. */
public CharacterAnvilNames(CharacterStorage storage, Consumer<Failure> failures, org.bukkit.inventory.ItemFactory items) {
@@ -44,9 +50,19 @@ public final class CharacterAnvilNames implements Listener {
if (name.equals(name(input))) return;
var player = event.getPlayer().getUniqueId();
var character = identity.orElseThrow().character();
storage.renameCharacter(character, name, identity.orElseThrow().generation()).whenComplete((renamed, failure) -> {
if (failure != null || renamed.isEmpty()) { failures.accept(new Failure(player, character, failure)); return; }
try { acknowledged.accept(renamed.orElseThrow()); }
var audit = identityEvents;
var action = audit == null ? null : audit.rename(event.getPlayer(), character, name);
storage.renameCharacterObserved(character, name, identity.orElseThrow().generation()).whenComplete((outcome, failure) -> {
if (failure != null || outcome.record().isEmpty()) {
failures.accept(new Failure(player, character, failure));
if (audit != null) audit.resolved(action, "refused", failure == null ? outcome.status() : "save_failed");
return;
}
if (audit != null) {
if ("renamed".equals(outcome.status())) audit.renamed(action, outcome.before().orElseThrow(), outcome.record().orElseThrow());
audit.resolved(action, "renamed".equals(outcome.status()) ? "accepted" : "refused", outcome.status());
}
try { acknowledged.accept(outcome.record().orElseThrow()); }
catch (RuntimeException | LinkageError error) { failures.accept(new Failure(player, character, error)); }
});
}
@@ -34,6 +34,14 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
private final Consumer<Failure> failures;
private final java.util.concurrent.ConcurrentMap<UUID, Attempt> pending = new java.util.concurrent.ConcurrentHashMap<>();
private volatile boolean closed;
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
private final java.util.Set<CraftItemEvent> handledCrafts =
java.util.Collections.newSetFromMap(new java.util.WeakHashMap<>());
public void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
if (identityEvents != null || !pending.isEmpty()) throw new IllegalStateException("Craft identity events already attached or attempts started");
identityEvents = java.util.Objects.requireNonNull(events);
}
// Server-thread reservation outlives close/reopen invalidation and includes client-sync callbacks.
private final java.util.Set<UUID> delivering = new java.util.HashSet<>();
@@ -50,22 +58,30 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
public void onCraft(CraftItemEvent event) {
if (event.isCancelled() || !(event.getRecipe() instanceof Keyed keyed)
|| !CharacterSheets.RECIPE.equals(keyed.getKey())) return;
handledCrafts.add(event); // MONITOR can distinguish our containment from an existing veto.
java.util.Optional<ManualCraftPlan> plan;
try {
plan = ManualCraftPlan.capture(event);
} catch (RuntimeException failure) {
failures.accept(new Failure(event.getWhoClicked().getUniqueId(), List.of(), "plan", failure));
refuseCraft(event, "failed", "plan");
return;
} finally {
// capture must see the original cancellation state, but exceptions must never expose the plain preview.
event.setCancelled(true);
}
if (closed || plan.isEmpty() || !(event.getWhoClicked() instanceof Player player) || !player.isOnline()
|| !plan.orElseThrow().matches(player.getOpenInventory())) return;
|| !plan.orElseThrow().matches(player.getOpenInventory())) {
refuseCraft(event, "refused", "invalid_or_stale_preflight");
return;
}
var attempt = new Attempt(player.getUniqueId(), player, plan.orElseThrow());
if (delivering.contains(attempt.id) || pending.putIfAbsent(attempt.id, attempt) != null) return;
var audit = identityEvents;
if (audit != null) attempt.craft = audit.craft(player, attempt.plan.route().name().toLowerCase(java.util.Locale.ROOT), attempt.plan.quantity());
try {
var records = java.util.stream.IntStream.range(0, attempt.plan.quantity()).mapToObj(index -> allocate.get()).toList();
attempt.records = records;
attempt.characters = records.stream().map(CharacterRecord::id).toList();
attempt.preparation = storage.prepareCraft(records);
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
@@ -73,9 +89,24 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
pending.remove(attempt.id, attempt);
abandon(attempt);
failures.accept(new Failure(attempt.id, attempt.characters, "prepare", failure));
if (audit != null) audit.resolved(attempt.craft, "failed", "prepare", 0);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onCraftDecision(CraftItemEvent event) {
if (!(event.getRecipe() instanceof Keyed keyed) || !CharacterSheets.RECIPE.equals(keyed.getKey())) return;
if (handledCrafts.remove(event) || !event.isCancelled()) return;
refuseCraft(event, "cancelled", "prior_cancellation");
}
private void refuseCraft(CraftItemEvent event, String outcome, String reason) {
var audit = identityEvents;
if (audit == null || !(event.getWhoClicked() instanceof Player player)) return;
var action = audit.craft(player, "unknown", 0);
audit.resolved(action, outcome, reason, 0);
}
private void schedule(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
if (closed || pending.get(attempt.id) != attempt) {
abandon(attempt);
@@ -98,22 +129,45 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
}
if (!delivering.add(attempt.id)) return;
try {
if (failure != null) failures.accept(new Failure(attempt.id, attempt.characters, "save", failure));
else if (attempt.player.isOnline()) {
var audit = identityEvents;
if (failure != null) {
failures.accept(new Failure(attempt.id, attempt.characters, "save", failure));
if (audit != null) audit.resolved(attempt.craft, "failed", "save", 0);
} else if (attempt.player.isOnline()) {
attempt.handoffStarted = true;
if (attempt.plan.route() == ManualCraftPlan.Route.DROP) {
var result = ManualDropDelivery.commit(attempt.plan, batch, attempt.player::getOpenInventory,
() -> !closed && pending.get(attempt.id) == attempt && attempt.player.isOnline(), drops.apply(attempt.player));
if (audit != null) {
for (int index = 0; index < result.completed(); index++) audit.created(attempt.craft, attempt.records.get(index), index);
var outcome = switch (result.status()) {
case COMPLETE -> "accepted";
case CANCELLED -> "cancelled";
case STALE -> "stale";
case UNCONFIRMED -> "uncertain";
case UNAVAILABLE, UNSUPPORTED_ROUTE -> "refused";
};
audit.resolved(attempt.craft, outcome,
"drop_" + result.status().name().toLowerCase(java.util.Locale.ROOT), result.completed());
}
if (result.status() != ManualDropDelivery.Status.COMPLETE) {
failures.accept(new Failure(attempt.id, attempt.characters, "drop-" + result.status().name().toLowerCase(java.util.Locale.ROOT), null));
}
} else {
var result = ManualCraftDelivery.commit(attempt.plan, batch, attempt.player.getOpenInventory());
attempt.handoffStarted = result == ManualCraftDelivery.Result.DELIVERED;
if (audit != null) {
if (attempt.handoffStarted) for (int index = 0; index < attempt.records.size(); index++)
audit.created(attempt.craft, attempt.records.get(index), index);
audit.resolved(attempt.craft, attempt.handoffStarted ? "accepted"
: result == ManualCraftDelivery.Result.STALE ? "stale" : "refused",
result.name().toLowerCase(java.util.Locale.ROOT), attempt.handoffStarted ? attempt.records.size() : 0);
}
}
}
} catch (RuntimeException unexpected) {
failures.accept(new Failure(attempt.id, attempt.characters, "delivery", unexpected));
if (identityEvents != null) identityEvents.resolved(attempt.craft, "uncertain", "delivery", 0);
} finally {
pending.remove(attempt.id, attempt);
abandon(attempt);
@@ -132,6 +186,8 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
final Player player;
final ManualCraftPlan plan;
List<UUID> characters = List.of();
List<CharacterRecord> records = List.of();
games.dmg.fantasystick.events.CharacterIdentityEventBridge.Craft craft;
CharacterStorage.CraftPreparation preparation;
volatile boolean handoffStarted;
final java.util.concurrent.atomic.AtomicBoolean cleanupObserved = new java.util.concurrent.atomic.AtomicBoolean();
@@ -11,37 +11,50 @@ import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
/** Idempotent recipe-book visibility; recipe registration and crafting remain separate authorities. */
public final class RecipeDiscovery implements Listener {
public final class RecipeDiscovery implements Listener, AutoCloseable {
private final NamespacedKey recipe;
private final Logger logger;
private games.dmg.fantasystick.events.RecipeDiscoveryEventBridge events;
public RecipeDiscovery(NamespacedKey recipe, Logger logger) {
this.recipe = Objects.requireNonNull(recipe, "recipe");
this.logger = Objects.requireNonNull(logger, "logger");
}
public void attachEvents(games.dmg.fantasystick.events.RecipeDiscoveryEventBridge bridge) {
events = Objects.requireNonNull(bridge);
}
public void discoverAll(Iterable<? extends Player> players) {
for (var player : players) discover(player);
for (var player : players) discover(player, "enable_online");
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
discover(event.getPlayer());
discover(event.getPlayer(), "join");
}
public void discover(Player player) {
public void discover(Player player) { discover(player, "join"); }
private void discover(Player player, String trigger) {
Objects.requireNonNull(player, "player");
try {
if (player.hasDiscoveredRecipe(recipe)) return;
player.discoverRecipes(List.of(recipe));
int notifications = player.discoverRecipes(List.of(recipe));
if (!player.hasDiscoveredRecipe(recipe)) {
logger.warning("Could not discover character-sheet recipe for " + label(player));
}
if (events != null) events.failed(player, trigger, "native_unavailable");
} else if (events != null) events.discovered(player, trigger, notifications);
} catch (RuntimeException failure) {
logger.log(Level.WARNING, "Could not discover character-sheet recipe for " + label(player), failure);
if (events != null) events.failed(player, trigger, "native_failed");
}
}
@Override public void close() {
if (events != null) events.close();
}
private static String label(Player player) {
try { return player.getName(); }
catch (RuntimeException ignored) { return "an unidentified player"; }
@@ -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,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,8 +14,16 @@ import org.bukkit.Location;
/** Stable feature-owned character-book outcomes; event delivery never authorizes or rolls back an open. */
public final class BookEventBridge {
public record Navigation(int sourceVolumeStart, int sourceNavigationPage, int destinationVolumeStart) {
public Navigation {
if (sourceVolumeStart < 0 || sourceNavigationPage < 1 || sourceNavigationPage > 100
|| destinationVolumeStart < 0 || sourceVolumeStart == destinationVolumeStart)
throw new IllegalArgumentException("Invalid book volume navigation");
}
}
public record Request(UUID actionId, EventInput.Context context, String actionKind, String viewKind,
int offset, BigInteger requestedRevision) {
int offset, BigInteger requestedRevision, Navigation navigation) {
public Request {
Objects.requireNonNull(actionId); Objects.requireNonNull(context); Objects.requireNonNull(actionKind);
Objects.requireNonNull(viewKind); Objects.requireNonNull(requestedRevision);
@@ -32,10 +40,15 @@ public final class BookEventBridge {
}
public Request request(ControlInput input, CharacterRecord current, int offset) {
Objects.requireNonNull(input); Objects.requireNonNull(current);
return request(input, current, offset, null);
}
public Request request(ControlInput input, CharacterRecord current, int offset, Navigation navigation) {
Objects.requireNonNull(input); Objects.requireNonNull(current);
var request = new Request(UUID.randomUUID(), context(input, current.name()),
offset == 0 ? "character_book_open" : "character_book_navigate", "active", offset,
current.revision());
navigation == null ? "character_book_open" : "character_book_navigate", "active", offset,
current.revision(), navigation);
emit("action.observed", request, "observed", null, null, null, null);
return request;
}
@@ -47,7 +60,7 @@ public final class BookEventBridge {
public void refused(org.bukkit.entity.Player player, games.dmg.fantasystick.session.EquipmentSessions.Active active,
CharacterRecord current, String actionKind, String reason) {
Objects.requireNonNull(player); Objects.requireNonNull(active); Objects.requireNonNull(current);
var request=new Request(UUID.randomUUID(),context(player,active,current.name()),actionKind,"active",-1,current.revision());
var request=new Request(UUID.randomUUID(),context(player,active,current.name()),actionKind,"active",-1,current.revision(),null);
emit("action.observed",request,"observed",null,null,null,null);
refused(request,reason);
}
@@ -60,7 +73,7 @@ public final class BookEventBridge {
Objects.requireNonNull(loaded);
emit("action.resolved", request, "accepted", "acknowledged", loaded.revision(), presentedPages,
presentedVolumes);
emit(request.offset() == 0 ? "character_book.opened" : "character_book.navigated", request,
emit(request.navigation() == null ? "character_book.opened" : "character_book.navigated", request,
"accepted", "acknowledged", loaded.revision(), presentedPages, presentedVolumes);
}
@@ -68,7 +81,18 @@ public final class BookEventBridge {
Integer pages, Integer volumes) {
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", request.actionKind()); data.put("view_kind", request.viewKind());
data.put("section", "character_inspection"); data.put("control_kind", "book"); data.put("control_slot", "0");
data.put("section", request.navigation() == null ? "overview" : "volume_navigation");
data.put("control_kind", "book"); data.put("control_slot", "0");
if (request.navigation() != null) {
var navigation = request.navigation();
data.put("source_volume_start", Integer.toString(navigation.sourceVolumeStart()));
data.put("source_navigation_page", navigation.sourceNavigationPage());
data.put("destination_volume_start", Integer.toString(navigation.destinationVolumeStart()));
data.put("destination_page", "1");
data.put("destination_section", navigation.destinationVolumeStart() == 0 ? "overview" : "volume_start");
data.put("volume_direction", navigation.destinationVolumeStart() > navigation.sourceVolumeStart()
? "next" : "previous");
}
data.put("offset", request.offset()<0?"unknown":Integer.toString(request.offset()));
data.put("requested_revision", request.requestedRevision().toString()); data.put("outcome", outcome);
if (reason != null) data.put(type.startsWith("character_book.") ? "native_open" : "reason", reason);
@@ -0,0 +1,246 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
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.Optional;
import java.util.UUID;
import java.nio.file.Path;
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. */
public final class CharacterIdentityEventBridge implements AutoCloseable {
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final FeatureEventOutbox outbox;
private final Clock clock;
private final Function<UUID, Optional<UUID>> activeSession;
private static final int MAX_RECENT_HOLDERS = 8192;
private final Map<UUID, Holder> lastObserved = new LinkedHashMap<>(16, .75f, true) {
@Override protected boolean removeEldestEntry(Map.Entry<UUID, Holder> eldest) {
return size() > MAX_RECENT_HOLDERS; // Eviction is uncertainty, never an invented transfer.
}
};
private record Holder(UUID player, UUID session, EventInput.Location entryLocation) { }
public CharacterIdentityEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path dataFolder, Function<UUID, Optional<UUID>> activeSession,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
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.issued"),
sink, Objects.requireNonNull(failures));
}
public void replay() { outbox.replay(); }
@Override public void close() { outbox.close(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
/** A later acknowledged activation proves a transfer only if we observed a different prior holder. */
public void entered(Player player, UUID session, CharacterRecord record) {
try {
var actor = new EventInput.Context(player.getUniqueId(), player.getName(), record.id(), record.name(),
session, location(player.getLocation()));
Holder previous;
synchronized (lastObserved) {
previous = lastObserved.put(record.id(), new Holder(actor.playerId(), session, actor.location()));
}
if (previous == null || previous.player().equals(actor.playerId())) return;
var action = id(previous.session(), session.toString());
var values = new LinkedHashMap<String, Object>();
values.put("character_id", record.id().toString());
values.put("generation", record.sheetGeneration().toString());
values.put("from_player_id", previous.player().toString());
values.put("to_player_id", actor.playerId().toString());
values.put("from_session_id", previous.session().toString());
values.put("to_session_id", session.toString());
values.put("observation_boundary", "acknowledged_activation");
values.put("custody_quality", "activation_after_prior_holder");
if (previous.entryLocation() != null) values.put("from_location", locationMap(previous.entryLocation(), "cached_at_previous_activation"));
else values.put("from_location_status", "unavailable");
if (actor.location() != null) values.put("to_location", locationMap(actor.location(), "exact"));
else values.put("to_location_status", "unavailable");
values.put("outcome", "observed");
emit("character.transfer_observed", action, clock.instant(), actor, values, "transfer_observed");
} 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) { }
public Craft craft(Player player, String route, int quantity) {
try {
var context = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
location(player.getLocation()));
var craft = new Craft(UUID.randomUUID(), clock.instant(), context, route, quantity,
activeSession.apply(context.playerId()).orElse(null));
emit("action.observed", craft.action(), craft.time(), context, Map.of(
"action_kind", "character_craft", "recipe_id", "fantasystick:character_sheet",
"delivery_route", route, "quantity", Integer.toString(quantity), "outcome", "observed"), "observed");
return craft;
} catch (RuntimeException ignored) { return null; }
}
public record Rename(UUID action, Instant time, EventInput.Context actor, UUID character,
String requestedName, UUID holderSession) { }
public Rename rename(Player player, UUID character, String requestedName) {
try {
var actor = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
location(player.getLocation()));
var action = new Rename(UUID.randomUUID(), clock.instant(), actor, character, requestedName,
activeSession.apply(actor.playerId()).orElse(null));
emit("action.observed", action.action(), action.time(), actor, Map.of(
"action_kind", "character_rename", "character_id", character.toString(),
"outcome", "observed"), "observed");
return action;
} catch (RuntimeException ignored) { return null; }
}
public void renamed(Rename action, CharacterRecord before, CharacterRecord after) {
if (action == null) return;
var values = new LinkedHashMap<String, Object>();
values.put("action_kind", "character_rename");
values.put("anvil_action_id", action.action().toString());
holder(values, action.actor().playerId(), action.holderSession());
values.put("character_id", after.id().toString());
values.put("generation", after.sheetGeneration().toString());
values.put("before_name", before.name());
values.put("after_name", after.name());
values.put("revision_before", before.revision().toString());
values.put("revision_after", after.revision().toString());
values.put("native_item_outcome", "unverified");
values.put("outcome", "acknowledged");
durable("character.renamed", action.action(), action.time(), action.actor(), values, "renamed");
}
public void resolved(Rename action, String outcome, String reason) {
if (action == null) return;
emit("action.resolved", action.action(), clock.instant(), action.actor(), Map.of(
"action_kind", "character_rename", "character_id", action.character().toString(),
"outcome", outcome, "reason", reason), "resolved");
}
public void created(Craft craft, CharacterRecord record, int index) {
if (craft == null) return;
var values = new LinkedHashMap<String, Object>();
values.put("action_kind", "character_craft");
values.put("craft_operation_id", craft.action().toString());
holder(values, craft.actor().playerId(), craft.holderSession());
values.put("character_id", record.id().toString());
values.put("character_name", record.name());
values.put("generation", record.sheetGeneration().toString());
values.put("revision", record.revision().toString());
values.put("recipe_id", "fantasystick:character_sheet");
values.put("quantity", Integer.toString(craft.quantity()));
values.put("index", index);
values.put("delivery_route", craft.route());
values.put("outcome", "acknowledged_delivered");
durable("character.created", craft.action(), craft.time(), craft.actor(), values, record.id().toString());
}
public void resolved(Craft craft, String outcome, String reason, int delivered) {
if (craft == null) return;
emit("action.resolved", craft.action(), clock.instant(), craft.actor(), Map.of(
"action_kind", "character_craft", "recipe_id", "fantasystick:character_sheet",
"delivery_route", craft.route(), "quantity", Integer.toString(craft.quantity()),
"delivered", Integer.toString(delivered), "outcome", outcome, "reason", reason), "resolved");
}
private void durable(String kind, UUID action, Instant time, EventInput.Context context,
Map<String, Object> values, String stage) {
try { outbox.accepted(new EventInput(id(action, stage), action, time, kind, context, values)); }
catch (RuntimeException ignored) { /* Auditing never changes acknowledged gameplay. */ }
}
private void emit(String kind, UUID action, Instant time, EventInput.Context context,
Map<String, Object> values, String stage) {
try { sink.apply(new EventInput(id(action, stage), action, time, kind, context, values)); }
catch (RuntimeException ignored) { /* The audit cannot change native craft outcomes. */ }
}
private static void holder(Map<String, Object> data, UUID player, UUID session) {
data.put("holder_player_id", player.toString());
data.put("holder_session_status", session == null ? "no_active_session" : "active_at_request");
if (session != null) data.put("holder_session_id", session.toString());
}
private static Map<String, Object> locationMap(EventInput.Location location, String quality) {
return Map.of("world_id", location.worldId().toString(), "world_name", location.worldName(),
"x", location.x(), "y", location.y(), "z", location.z(),
"captured_at", location.capturedAt().toString(), "quality", quality);
}
private EventInput.Location location(Location value) {
return value == null || value.getWorld() == null ? null
: 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 stage) {
return UUID.nameUUIDFromBytes(("fantasystick:character-identity:" + action + ':' + stage)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,164 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.items.EquipmentStorage;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.math.BigInteger;
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.nio.file.Path;
import java.util.Set;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Observes only proved native box custody and the owning acknowledged receipt boundary. */
public final class EquipmentEventBridge implements AutoCloseable {
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final FeatureEventOutbox outbox;
private final Clock clock;
public EquipmentEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path dataFolder, Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.outbox = new FeatureEventOutbox(Objects.requireNonNull(dataFolder), "equipment-event-intents",
Set.of("equipment.packed", "equipment.restored", "equipment.recovery_resolved"), sink,
Objects.requireNonNull(failures));
}
public void replay() { outbox.replay(); }
@Override public void close() { outbox.close(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
public record Capture(EventInput.Context context, EquipmentStorage.Snapshot box, Instant observedAt,
UUID session, UUID character) { }
public record Action(UUID id, Instant time, EventInput.Context context, UUID session, UUID character,
String kind) { }
public Action action(Player player, UUID session, UUID character, String kind) {
try {
// A receipt proves the character UUID, not its current display name. Keep that UUID
// in the payload rather than inventing an incomplete character envelope context.
var context = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
location(player.getLocation()));
var action = new Action(UUID.randomUUID(), clock.instant(), context, session, character, kind);
var values = actionFields(action);
values.put("outcome", "observed");
emitAction(action, "action.observed", "observed", values);
return action;
} catch (RuntimeException ignored) { return null; }
}
public void resolved(Action action, String outcome, String reason) {
if (action == null) return;
var values = actionFields(action);
values.put("outcome", outcome);
values.put("reason", reason);
emitAction(action, "action.resolved", "resolved", values);
}
public void recovery(Action action, UUID session, UUID character, String priorPhase, String currentPhase,
String quality, String outcome, String reason, BigInteger receiptRevision) {
if (action == null) return;
var values = actionFields(action);
values.put("recovery_operation_id", action.id().toString());
values.put("session_id", session.toString());
values.put("character_id", character.toString());
values.put("prior_phase", priorPhase);
values.put("current_phase", currentPhase);
values.put("custody_quality", quality);
values.put("outcome", outcome);
values.put("reason", reason);
if (receiptRevision != null) values.put("receipt_revision", receiptRevision.toString());
try { outbox.accepted(new EventInput(id(action.id(), "recovery_resolved"), action.id(), clock.instant(),
"equipment.recovery_resolved", action.context(), values)); }
catch (RuntimeException ignored) { }
}
private LinkedHashMap<String, Object> actionFields(Action action) {
var values = new LinkedHashMap<String, Object>();
values.put("action_kind", action.kind());
if (action.session() != null) values.put("session_id", action.session().toString());
if (action.character() != null) values.put("character_id", action.character().toString());
return values;
}
private void emitAction(Action action, String kind, String stage, Map<String, Object> values) {
try { sink.apply(new EventInput(id(action.id(), stage + ':' + values.get("outcome") + ':' + values.getOrDefault("reason", "")), action.id(),
stage.equals("observed") ? action.time() : clock.instant(), kind, action.context(), values)); }
catch (RuntimeException ignored) { }
}
public Capture capture(Player player, UUID session, CharacterRecord character, EquipmentStorage.Snapshot box) {
return capture(player, session, character.id(), character.name(), box);
}
/** Recovery can prove a receipt's character UUID without proving a current character name. */
public Capture capture(Player player, UUID session, UUID character, String characterName,
EquipmentStorage.Snapshot box) {
try {
return new Capture(new EventInput.Context(player.getUniqueId(), player.getName(),
characterName == null ? null : character, characterName,
characterName == null ? null : session, location(player.getLocation())),
box, clock.instant(), session, character);
} catch (RuntimeException ignored) { return null; }
}
public void packed(Capture captured, BigInteger revision) {
if (captured == null || revision == null) return;
var values = fields(captured, revision);
values.put("box_custody", "main_storage");
values.put("outcome", "acknowledged_packed");
emit(captured, "equipment.packed", "packed", values);
}
public void restored(Capture captured, BigInteger revision) {
if (captured == null || revision == null) return;
var values = fields(captured, revision);
values.put("displaced_head_handling", captured.box().displacedHead()
? captured.box().boxSource().equals("cursor") ? "cursor" : "freed_box_slot" : "none");
values.put("outcome", "acknowledged_restored");
emit(captured, "equipment.restored", "restored", values);
}
private LinkedHashMap<String, Object> fields(Capture captured, BigInteger revision) {
var box = captured.box();
var values = new LinkedHashMap<String, Object>();
values.put("session_id", captured.session().toString());
values.put("character_id", captured.character().toString());
values.put("equipment_operation_id", captured.session().toString());
values.put("receipt_revision", revision.toString());
values.put("slot_count", box.packedSlots());
values.put("slot_classes", box.slotClasses());
values.put("hotbar_slot_count", box.hotbarSlots());
values.put("armor_slot_count", box.armorSlots());
values.put("box_source", box.boxSource());
values.put("box_slot", box.boxSlot());
return values;
}
private void emit(Capture captured, String type, String phase, Map<String, Object> values) {
try { outbox.accepted(new EventInput(id(captured.session(), phase), captured.session(), captured.observedAt(),
type, captured.context(), values)); }
catch (RuntimeException ignored) { /* Audit failures cannot change native equipment custody. */ }
}
private EventInput.Location location(Location value) {
return value == null || value.getWorld() == null ? null
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
value.getZ(), clock.instant(), "exact");
}
private static UUID id(UUID session, String stage) {
return UUID.nameUUIDFromBytes(("fantasystick:equipment:" + session + ':' + stage)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,239 @@
package games.dmg.fantasystick.events;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
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.Instant;
import java.util.LinkedHashMap;
import java.util.Properties;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Function;
/** Per-feature recoverable intent for acknowledged domain outcomes. Never replays gameplay. */
final class FeatureEventOutbox implements AutoCloseable {
private static final int MAX_BYTES = 16_384;
private final Set<String> types;
private final Path directory;
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Consumer<Throwable> failures;
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
new Thread(task, "fantasy-feature-event-intents"));
private final CompletableFuture<Void> drained = new CompletableFuture<>();
FeatureEventOutbox(Path data, String folder, Set<String> types,
Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Consumer<Throwable> failures) {
if (!folder.matches("[a-z-]+")) throw new IllegalArgumentException("Unsafe event intent folder");
directory = data.toAbsolutePath().resolve(folder);
this.types = Set.copyOf(types);
this.sink = sink;
this.failures = failures;
}
void accepted(EventInput event) {
try { worker.execute(() -> {
try { publish(write(event)); } catch (IOException | RuntimeException failure) { report(failure); }
}); } catch (RuntimeException failure) { report(failure); }
}
void replay() {
if (worker.isShutdown()) return;
try { worker.execute(() -> {
try {
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return;
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event directory");
try (var files = Files.list(directory)) {
for (var path : files.filter(value -> value.getFileName().toString().endsWith(".pending")).sorted().toList()) {
try { publish(read(path)); } catch (IOException | RuntimeException failure) { report(failure); }
}
}
} catch (IOException | RuntimeException failure) { report(failure); }
}); } catch (RuntimeException failure) { report(failure); }
}
private Path path(EventInput event) { return directory.resolve(event.id() + ".pending"); }
private EventInput write(EventInput event) throws IOException {
if (!types.contains(event.type())) throw new IOException("Unsupported character intent type");
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event directory");
Files.createDirectories(directory);
var target = path(event);
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return read(target);
var fields = new Properties();
fields.setProperty("id", event.id().toString());
fields.setProperty("action", event.actionId().toString());
fields.setProperty("time", event.time().toString());
fields.setProperty("type", event.type());
var context = event.context();
if (context != null) {
fields.setProperty("player", context.playerId().toString());
fields.setProperty("player_name", context.playerName());
}
if (context != null && context.characterId() != null) {
fields.setProperty("character", context.characterId().toString());
fields.setProperty("character_name", context.characterName());
fields.setProperty("session", context.sessionId().toString());
}
if (context != null && context.location() != null) {
var location = context.location();
fields.setProperty("world", location.worldId().toString());
fields.setProperty("world_name", location.worldName());
fields.setProperty("x", Double.toString(location.x()));
fields.setProperty("y", Double.toString(location.y()));
fields.setProperty("z", Double.toString(location.z()));
fields.setProperty("captured_at", location.capturedAt().toString());
fields.setProperty("quality", location.quality());
}
event.data().forEach((key, 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");
var bytes = output.toByteArray();
if (bytes.length > MAX_BYTES) throw new IOException("Character event intent too large");
var temporary = Files.createTempFile(directory, ".identity-", ".tmp");
try {
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) channel.write(buffer);
channel.force(true);
}
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
forceDirectory();
} finally { Files.deleteIfExists(temporary); }
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();
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
var bytes = input.readNBytes(MAX_BYTES + 1);
if (bytes.length > MAX_BYTES) throw new IOException("Character event intent too large");
fields.load(new ByteArrayInputStream(bytes));
}
try {
var id = UUID.fromString(fields.getProperty("id"));
var action = UUID.fromString(fields.getProperty("action"));
var type = fields.getProperty("type");
if (!path.equals(directory.resolve(id + ".pending")) || !types.contains(type))
throw new IOException("Invalid character event intent identity");
EventInput.Location location = fields.containsKey("world")
? new EventInput.Location(UUID.fromString(fields.getProperty("world")), fields.getProperty("world_name"),
Double.parseDouble(fields.getProperty("x")), Double.parseDouble(fields.getProperty("y")),
Double.parseDouble(fields.getProperty("z")), Instant.parse(fields.getProperty("captured_at")),
fields.getProperty("quality")) : null;
var character = fields.containsKey("character") ? UUID.fromString(fields.getProperty("character")) : null;
if (!fields.containsKey("player") && (character != null || fields.containsKey("session") || location != null))
throw new IOException("Unbound event context");
var context = fields.containsKey("player")
? new EventInput.Context(UUID.fromString(fields.getProperty("player")),
fields.getProperty("player_name"), character, fields.getProperty("character_name"),
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");
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");
yield Boolean.valueOf(value.substring(1));
}
case 'i' -> Integer.valueOf(value.substring(1));
case 'd' -> {
var number = Double.valueOf(value.substring(1));
if (!Double.isFinite(number)) throw new IOException("Non-finite event coordinate");
yield number;
}
case 'l' -> {
var encoded = value.substring(1);
var list = encoded.isEmpty() ? java.util.List.<String>of() : java.util.List.of(encoded.split(","));
if (!list.stream().allMatch(item -> item.equals("hotbar") || item.equals("armor")))
throw new IOException("Invalid slot class");
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); }
}
private void publish(EventInput event) {
try { sink.apply(event).whenComplete((delivery, failure) -> {
if (failure != null) { report(failure); return; }
if (delivery == LocalEventLog.Delivery.REJECTED) return;
try { if (Files.deleteIfExists(path(event))) forceDirectory(); }
catch (IOException error) { report(error); }
}); } catch (RuntimeException failure) { report(failure); }
}
private void forceDirectory() throws IOException {
try (var channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); }
}
private void report(Throwable error) {
try { failures.accept(error); } catch (RuntimeException ignored) { }
}
@Override public void close() {
worker.shutdown();
CompletableFuture.runAsync(() -> {
try {
if (!worker.awaitTermination(5, TimeUnit.SECONDS)) report(new IOException("Character event intents did not drain"));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt(); report(interrupted);
} finally { drained.complete(null); }
});
}
CompletionStage<Void> drained() { return drained; }
}
@@ -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));
}
}
@@ -0,0 +1,150 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.party.CombatSources;
import games.dmg.fantasystick.party.PartyRoster;
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.entity.Player;
import org.bukkit.event.entity.EntityDamageEvent;
/** Captures native protection decisions without changing the native damage/event authority. */
public final class PartyProtectionEventBridge {
public record Action(UUID id, Instant observedAt, EventInput.Context actor, Map<String, Object> data) { }
private final Consumer<EventInput> sink;
private final Clock clock;
public PartyProtectionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Action damage(EntityDamageEvent event, CombatSources.Origin origin,
CombatSources.Origin recipient, Player target,
PartyRoster.Party sourceParty, PartyRoster.Party targetParty, String reason) {
try {
var id = UUID.randomUUID();
var time = clock.instant();
Entity causing = event.getDamageSource().getCausingEntity();
Entity direct = event.getDamageSource().getDirectEntity();
var source = causing instanceof Player player && player.getUniqueId().equals(origin.player()) ? player : null;
var context = source == null ? null : new EventInput.Context(source.getUniqueId(), source.getName(),
null, null, null, location(source.getLocation()));
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "party_damage_protection");
if (context == null) data.put("actor_kind", "system");
data.put("damage_cause", event.getCause().name().toLowerCase(java.util.Locale.ROOT));
data.put("source_kind", causing == null ? "provenance" : causing instanceof Player ? "player"
: causing.getType() == null ? "unavailable" : causing.getType().name().toLowerCase(java.util.Locale.ROOT));
data.put("source_player_id", origin.player().toString());
if (causing != null && causing.getUniqueId() != null)
data.put("causing_entity_id", causing.getUniqueId().toString());
if (direct != null && direct.getUniqueId() != null)
data.put("direct_entity_id", direct.getUniqueId().toString());
if (origin.character() != null) data.put("source_character_id", origin.character().toString());
if (origin.session() != null) data.put("source_session_id", origin.session().toString());
data.put("target_player_id", target.getUniqueId().toString());
if (recipient.character() != null) data.put("target_character_id", recipient.character().toString());
if (recipient.session() != null) data.put("target_session_id", recipient.session().toString());
if (sourceParty != null) data.put("source_party_id", sourceParty.id().toString());
if (targetParty != null) data.put("target_party_id", targetParty.id().toString());
data.put("provenance_quality", origin.session() == null ? "ordinary_or_unproved"
: reason.equals("stale_source") ? "claimed_session_unverified" : "matching_session_id");
data.put("provenance_age_status", "unavailable");
data.put("requested_amount", event.getDamage());
data.put("prior_cancelled", event.isCancelled());
data.put("proposed_reason", reason);
var sourceLocation = causing == null ? null : location(causing.getLocation());
var targetLocation = 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(id, time, context, Map.copyOf(data));
var observed = new LinkedHashMap<String, Object>(data);
observed.put("outcome", "observed");
emit(action, "action.observed", "observed", observed);
return action;
} catch (RuntimeException ignored) { return null; }
}
public Action nativeAttempt(String kind, Entity causing, Player target, CombatSources.Origin origin,
CombatSources.Origin recipient, PartyRoster.Party sourceParty,
PartyRoster.Party targetParty, String reason, String nativeKind) {
try {
var id = UUID.randomUUID();
var now = clock.instant();
var source = causing instanceof Player player && player.getUniqueId().equals(origin.player()) ? player : null;
var context = source == null ? null : new EventInput.Context(source.getUniqueId(), source.getName(),
null, null, null, location(source.getLocation()));
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", kind);
if (context == null) data.put("actor_kind", "system");
data.put("native_kind", nativeKind);
data.put("source_player_id", origin.player().toString());
if (causing != null && causing.getUniqueId() != null)
data.put("direct_entity_id", causing.getUniqueId().toString());
if (origin.character() != null) data.put("source_character_id", origin.character().toString());
if (origin.session() != null) data.put("source_session_id", origin.session().toString());
if (recipient != null) {
data.put("target_player_id", recipient.player().toString());
if (recipient.character() != null) data.put("target_character_id", recipient.character().toString());
if (recipient.session() != null) data.put("target_session_id", recipient.session().toString());
} else data.put("target_status", "not_single_proved_player");
if (sourceParty != null) data.put("source_party_id", sourceParty.id().toString());
if (targetParty != null) data.put("target_party_id", targetParty.id().toString());
data.put("provenance_quality", origin.session() == null ? "ordinary_or_unproved"
: reason.equals("stale_source") ? "claimed_session_unverified" : "matching_session_id");
data.put("provenance_age_status", "unavailable");
data.put("requested_amount_status", "not_applicable");
data.put("prior_cancelled", false);
var sourceLocation = causing == null ? null : location(causing.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(id, now, context, Map.copyOf(data));
var observed = new LinkedHashMap<String, Object>(data);
observed.put("outcome", "observed");
emit(action, "action.observed", "observed", observed);
return action;
} catch (RuntimeException ignored) { return null; }
}
public void resolved(Action action, String outcome, String reason, boolean cancelled, Double finalAmount) {
if (action == null) return;
var values = new LinkedHashMap<String, Object>(action.data());
values.put("outcome", outcome);
values.put("reason", reason);
values.put("final_cancelled", cancelled);
if (finalAmount != null) values.put("final_amount", finalAmount);
else values.put("final_amount_status", "not_applicable");
emit(action, "action.resolved", "resolved", values);
}
private EventInput.Location location(Location value) {
return value == null || value.getWorld() == null ? null
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(),
value.getY(), value.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, Map<String, Object> data) {
try { sink.accept(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:party-protection:" + action.id()
+ ':' + stage).getBytes(StandardCharsets.UTF_8)), action.id(),
stage.equals("observed") ? action.observedAt() : clock.instant(), type, action.actor(), data)); }
catch (RuntimeException ignored) { }
}
}
@@ -0,0 +1,223 @@
package games.dmg.fantasystick.events;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
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.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.entity.Player;
/** An acknowledged native discovery has one recoverable, account-keyed event intent. */
public final class RecipeDiscoveryEventBridge implements AutoCloseable {
private static final String RECIPE = "fantasystick:character_sheet";
private static final int MAX_BYTES = 16_384;
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final Path pending;
private final Consumer<Throwable> failures;
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
new Thread(task, "fantasy-recipe-discovery-events"));
public RecipeDiscoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path dataFolder, Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.pending = Objects.requireNonNull(dataFolder).toAbsolutePath().resolve("recipe-event-intents");
this.failures = Objects.requireNonNull(failures);
}
public void discovered(Player player, String trigger, int notifications) {
try {
var record = capture(player, trigger, notifications > 0);
worker.execute(() -> {
try { publish(write(record)); }
catch (IOException | RuntimeException failure) { report(failure); }
});
} catch (RuntimeException failure) { report(failure); }
}
/** Called only after the shared writer's startup recovery is ready. */
public void replay() {
try {
worker.execute(() -> {
try {
if (!Files.exists(pending, LinkOption.NOFOLLOW_LINKS)) return;
if (!Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intents");
try (var entries = Files.list(pending)) {
for (var path : entries.filter(p -> p.getFileName().toString().endsWith(".pending")).sorted().toList()) {
try { publish(read(path)); }
catch (IOException | RuntimeException failure) { report(failure); }
}
}
} catch (IOException | RuntimeException failure) { report(failure); }
});
} catch (RuntimeException failure) { report(failure); }
}
public void failed(Player player, String trigger, String reason) {
try {
var action = UUID.randomUUID();
var context = context(player);
emit(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:recipe:" + action + ":failed")
.getBytes(StandardCharsets.UTF_8)), action, clock.instant(), "action.resolved", context,
Map.of("action_kind", "recipe_discovery", "recipe_id", RECIPE, "trigger", trigger,
"outcome", "failed", "reason", reason)));
} catch (RuntimeException failure) { report(failure); }
}
private record Record(UUID account, UUID action, Instant time, String name, String trigger,
boolean notification, EventInput.Location location) { }
private Record capture(Player player, String trigger, boolean notification) {
var context = context(player);
return new Record(context.playerId(), id(context.playerId()), clock.instant(), context.playerName(),
trigger, notification, context.location());
}
private EventInput.Context context(Player player) {
var location = player.getLocation();
EventInput.Location where = location == null || location.getWorld() == null ? null
: new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(),
location.getX(), location.getY(), location.getZ(), clock.instant(), "exact");
return new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null, where);
}
private void publish(Record record) {
var fields = new LinkedHashMap<String, Object>();
fields.put("action_kind", "recipe_discovery");
fields.put("recipe_id", RECIPE);
fields.put("recipe_category", "misc");
fields.put("trigger", record.trigger());
fields.put("outcome", "acknowledged");
fields.put("first_discovery_notification", record.notification());
var input = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:recipe:" + record.action()
+ ":recipe.discovered").getBytes(StandardCharsets.UTF_8)), record.action(), record.time(),
"recipe.discovered", new EventInput.Context(record.account(), record.name(), null, null, null,
record.location()), fields);
emit(input).whenComplete((delivery, failure) -> {
if (failure != null) { report(failure); return; }
if (delivery == LocalEventLog.Delivery.REJECTED) return;
try {
if (Files.deleteIfExists(file(record.account()))) forceDirectory();
} catch (IOException deleteFailure) { report(deleteFailure); }
});
}
private CompletionStage<LocalEventLog.Delivery> emit(EventInput input) {
try { return sink.apply(input); }
catch (RuntimeException failure) {
report(failure);
return java.util.concurrent.CompletableFuture.completedStage(LocalEventLog.Delivery.REJECTED);
}
}
private Path file(UUID account) { return pending.resolve(account + ".pending"); }
private Record write(Record record) throws IOException {
if (Files.exists(pending, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intents");
Files.createDirectories(pending);
Path target = file(record.account());
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intent");
return read(target); // Preserve the first native acknowledgment and its original identity.
}
var values = new Properties();
values.setProperty("account", record.account().toString());
values.setProperty("action", record.action().toString());
values.setProperty("time", record.time().toString());
values.setProperty("name", record.name());
values.setProperty("trigger", record.trigger());
values.setProperty("notification", Boolean.toString(record.notification()));
if (record.location() != null) {
var location = record.location();
values.setProperty("world", location.worldId().toString());
values.setProperty("world_name", location.worldName());
values.setProperty("x", Double.toString(location.x()));
values.setProperty("y", Double.toString(location.y()));
values.setProperty("z", Double.toString(location.z()));
values.setProperty("location_time", location.capturedAt().toString());
}
var output = new ByteArrayOutputStream();
values.store(output, "acknowledged native recipe discovery");
var bytes = output.toByteArray();
if (bytes.length > MAX_BYTES) throw new IOException("Recipe event intent too large");
Path temporary = Files.createTempFile(pending, ".recipe-", ".tmp");
try {
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) channel.write(buffer);
channel.force(true);
}
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
forceDirectory();
} finally { Files.deleteIfExists(temporary); }
return record;
}
private void forceDirectory() throws IOException {
try (var channel = FileChannel.open(pending, StandardOpenOption.READ)) { channel.force(true); }
}
private Record read(Path path) throws IOException {
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intent");
var values = new Properties();
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
var bytes = input.readNBytes(MAX_BYTES + 1);
if (bytes.length > MAX_BYTES) throw new IOException("Recipe event intent too large");
values.load(new java.io.ByteArrayInputStream(bytes));
}
try {
var account = UUID.fromString(values.getProperty("account"));
var action = UUID.fromString(values.getProperty("action"));
if (!path.equals(file(account)) || !action.equals(id(account))) throw new IOException("Invalid recipe event identity");
EventInput.Location location = values.containsKey("world")
? new EventInput.Location(UUID.fromString(values.getProperty("world")), values.getProperty("world_name"),
Double.parseDouble(values.getProperty("x")), Double.parseDouble(values.getProperty("y")),
Double.parseDouble(values.getProperty("z")), Instant.parse(values.getProperty("location_time")), "exact")
: null;
var trigger = values.getProperty("trigger");
if (!"join".equals(trigger) && !"enable_online".equals(trigger)) throw new IOException("Invalid recipe event trigger");
var notification = values.getProperty("notification");
if (!"true".equals(notification) && !"false".equals(notification)) throw new IOException("Invalid recipe notification");
return new Record(account, action, Instant.parse(values.getProperty("time")),
Objects.requireNonNull(values.getProperty("name")), trigger, Boolean.parseBoolean(notification), location);
} catch (RuntimeException invalid) { throw new IOException("Invalid recipe event intent", invalid); }
}
public static UUID id(UUID player) {
return UUID.nameUUIDFromBytes(("fantasystick:recipe:character_sheet:" + player)
.getBytes(StandardCharsets.UTF_8));
}
private void report(Throwable failure) {
try { failures.accept(failure); } catch (RuntimeException ignored) { }
}
@Override public void close() {
worker.shutdown();
try {
if (!worker.awaitTermination(5, TimeUnit.SECONDS)) report(new IOException("Recipe event intents did not drain"));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt(); report(interrupted);
}
}
}
@@ -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,200 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.AdventureSettings;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
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.Optional;
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.command.CommandSender;
import org.bukkit.entity.Player;
/** Captured administrative/session recovery observations; never authorizes native recovery. */
public final class SessionRecoveryEventBridge implements AutoCloseable {
public record Action(UUID id, Instant observedAt, EventInput.Context context, String actorKind,
String locationStatus, UUID target) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final FeatureEventOutbox outbox;
private final Consumer<Throwable> failures;
private final Clock clock;
public SessionRecoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.failures = Objects.requireNonNull(failures);
outbox = new FeatureEventOutbox(data, "session-recovery-event-intents",
Set.of("recovery.resolved", "config.changed"), sink, failures);
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
public Action capture(CommandSender sender, UUID target) {
try {
var now = clock.instant();
if (sender instanceof Player player) {
var location = player.getLocation();
EventInput.Location origin = location == null || location.getWorld() == null ? null
: new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(),
location.getX(), location.getY(), location.getZ(), now, "exact");
return new Action(UUID.randomUUID(), now,
new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null, origin),
"player", origin == null ? "unavailable" : null, target);
}
return new Action(UUID.randomUUID(), now, null,
sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console",
"not_applicable", target);
} catch (RuntimeException ignored) { return null; }
}
public void observed(Action action, String kind) {
if (action == null) return;
var data = fields(action);
data.put("action_kind", kind);
data.put("outcome", "observed");
emit(action, "action.observed", "observed", data);
}
public void resolved(Action action, String kind, String outcome, String reason) {
if (action == null) return;
var data = fields(action);
if (outcome.equals("refused")) data.remove("target_player_id");
data.put("action_kind", kind);
data.put("outcome", outcome);
data.put("reason", reason);
emit(action, "action.resolved", "resolved:" + outcome + ':' + reason, data);
}
public void inspected(Action action, Optional<EquipmentSessionStore.State> state,
String nativePhase, boolean online) {
if (action == null) return;
var data = fields(action);
data.put("action_kind", "equipment_inspect");
data.put("outcome", "authorized");
var saved = state.orElse(null);
var receipt = saved == null ? null : saved.pending();
data.put("receipt_phase", receipt != null ? "pending" : saved == null ? "absent" : "completed");
if (saved != null) data.put("receipt_revision", saved.revision().toString());
if (receipt != null) {
data.put("session_id", receipt.sessionId().toString());
data.put("character_id", receipt.characterId().toString());
}
data.put("native_phase", online ? nativePhase : "offline_unavailable");
data.put("custody_quality", online ? "receipt_and_native_observation" : "receipt_only");
emit(action, "recovery.inspected", "inspected", data);
}
/** One result per proof-based reconciliation attempt, distinct from native equipment custody. */
public void reconciled(Action action, UUID session, UUID character, String priorPhase,
String currentPhase, String outcome, String quality, String reason,
java.math.BigInteger priorRevision, java.math.BigInteger afterRevision,
UUID equipmentOperation, EventInput.Location original, EventInput.Location current) {
if (action == null) return;
var data = fields(action);
data.put("recovery_operation_id", action.id().toString());
if (session != null) data.put("session_id", session.toString());
if (character != null) data.put("character_id", character.toString());
data.put("prior_phase", priorPhase);
data.put("current_phase", currentPhase);
data.put("custody_quality", quality);
data.put("outcome", outcome);
data.put("reason", reason);
if (priorRevision != null) data.put("prior_receipt_revision", priorRevision.toString());
if (afterRevision != null) data.put("receipt_revision", afterRevision.toString());
if (equipmentOperation != null) data.put("equipment_recovery_operation_id", equipmentOperation.toString());
nativeLocation(data, "original", original);
nativeLocation(data, "current", current);
data.put("changed_components", outcome.equals("restored") && quality.equals("verified_box_and_marker")
? "verified_native_equipment" : quality.equals("native_restored_receipt_pending")
? "native_equipment_unacknowledged" : "not_independently_verified");
emit(action, "recovery.resolved", "reconciled", data);
if (!action.actorKind().equals("system")) resolved(action, "equipment_recovery", outcome, reason);
}
/** Must be called on the native thread before any asynchronous receipt acknowledgment. */
public EventInput.Location captureLocation(Location value) {
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");
}
private void nativeLocation(LinkedHashMap<String, Object> data, String name, EventInput.Location location) {
if (location == null) {
data.put(name + "_location_status", "unavailable");
return;
}
data.put(name + "_world_id", location.worldId().toString());
data.put(name + "_world_name", location.worldName());
data.put(name + "_x", location.x());
data.put(name + "_y", location.y());
data.put(name + "_z", location.z());
data.put(name + "_captured_at", location.capturedAt().toString());
data.put(name + "_location_status", name.equals("original") ? "captured_at_entry" : "captured_at_reconciliation");
}
public Action system(Player player) {
try {
var now = clock.instant();
var location = player.getLocation();
var action = new Action(UUID.randomUUID(), now, null, "system",
location == null || location.getWorld() == null ? "unavailable" : "captured_current_only",
player.getUniqueId());
return action;
} catch (RuntimeException ignored) { return null; }
}
public void configRead(Action action, boolean effective, Optional<String> revision) {
if (action == null) return;
var data = fields(action);
data.put("key", "adventure-mode");
data.put("effective_value", Boolean.toString(effective));
if (revision.isPresent()) data.put("persistence_revision", revision.orElseThrow());
else data.put("persistence_revision_status", "not_verified_at_read");
data.put("outcome", "authorized");
emit(action, "config.read", "read", data);
}
public void configChanged(Action action, AdventureSettings.Saved saved) {
if (action == null || saved == null) return;
var data = fields(action);
data.put("key", "adventure-mode");
data.put("before_value", Boolean.toString(saved.beforeValue()));
data.put("after_value", Boolean.toString(saved.value()));
data.put("effective_value", Boolean.toString(saved.value()));
data.put("persistence_revision", saved.revision());
data.put("application", "future_sessions");
data.put("outcome", "acknowledged");
emit(action, "config.changed", "changed", data);
}
private LinkedHashMap<String, Object> fields(Action action) {
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", action.actorKind());
if (action.locationStatus() != null) data.put("location_status", action.locationStatus());
if (action.target() != null) data.put("target_player_id", action.target().toString());
return data;
}
private void emit(Action action, String type, String stage, LinkedHashMap<String, Object> data) {
try {
var event = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:session-recovery:" + action.id()
+ ':' + stage).getBytes(StandardCharsets.UTF_8)), action.id(),
stage.equals("observed") ? action.observedAt() : clock.instant(), type, action.context(), data);
if (type.equals("config.changed") || type.equals("recovery.resolved")) outbox.accepted(event);
else sink.apply(event);
}
catch (RuntimeException failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
}
}
@@ -0,0 +1,265 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
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.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Observes persisted switch deadlines and pre-pack admission without authorizing either. */
public final class SwitchDelayEventBridge implements AutoCloseable {
private static final int MAX_BYTES = 16_384;
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final Path pending;
private final Consumer<Throwable> failures;
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
new Thread(task, "fantasy-switch-delay-events"));
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public SwitchDelayEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
Path dataFolder, Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.pending = Objects.requireNonNull(dataFolder).toAbsolutePath().resolve("switch-event-intents");
this.failures = Objects.requireNonNull(failures);
}
public record ExitContext(UUID player, String playerName, UUID character, String characterName,
UUID session, EventInput.Location location, String reason) { }
public ExitContext exit(Player player, UUID session, CharacterRecord character, String reason) {
try {
return new ExitContext(player.getUniqueId(), player.getName(), character.id(), character.name(),
session, location(player.getLocation()), reason);
} catch (RuntimeException ignored) { return null; }
}
private record SavedStart(ExitContext capture, EquipmentSessionStore.Exit exit,
String revisionBefore, String revisionAfter, Instant time) { }
public void started(ExitContext captured, EquipmentSessionStore.State saved) {
if (captured == null || saved == null || saved.pending() != null || saved.lastExit() == null
|| !captured.session().equals(saved.lastExit().sessionId())) return;
var start = new SavedStart(captured, saved.lastExit(),
saved.revision().subtract(java.math.BigInteger.ONE).toString(), saved.revision().toString(),
saved.lastExit().exitedAt());
try { worker.execute(() -> {
try { publish(write(start)); }
catch (IOException | RuntimeException failure) { report(failure); }
}); } catch (RuntimeException rejected) { report(rejected); }
}
private void publish(SavedStart start) {
var captured = start.capture();
var exit = start.exit();
var values = new LinkedHashMap<String, Object>();
values.put("outcome", "acknowledged");
values.put("exit_reason", captured.reason());
values.put("exit_operation_id", captured.session().toString());
values.put("character_id", exit.characterId().toString());
values.put("session_id", exit.sessionId().toString());
values.put("duration_seconds", Long.toString(Duration.between(exit.exitedAt(), exit.deadline()).getSeconds()));
values.put("exited_at", exit.exitedAt().toString());
values.put("deadline", exit.deadline().toString());
values.put("revision_before", start.revisionBefore());
values.put("revision_after", start.revisionAfter());
values.put("zero_duration", exit.deadline().equals(exit.exitedAt()));
var input = new EventInput(id(captured.session(), "started"), captured.session(), start.time(),
"character.switch_delay_started", new EventInput.Context(captured.player(), captured.playerName(),
captured.character(), captured.characterName(), captured.session(), captured.location()), values);
try { sink.apply(input).whenComplete((delivery, failure) -> {
if (failure != null) { report(failure); return; }
if (delivery == LocalEventLog.Delivery.REJECTED) return;
try { if (Files.deleteIfExists(file(captured.session()))) forceDirectory(); }
catch (IOException error) { report(error); }
}); } catch (RuntimeException failure) { report(failure); }
}
/** Replay only persisted, previously acknowledged exits, before any new native session observations. */
public void replay() {
if (worker.isShutdown()) return;
try { worker.execute(() -> {
try {
if (!Files.exists(pending, LinkOption.NOFOLLOW_LINKS)) return;
if (!Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe switch event intents");
try (var entries = Files.list(pending)) {
for (var path : entries.filter(p -> p.getFileName().toString().endsWith(".pending")).sorted().toList()) {
try { publish(read(path)); }
catch (IOException | RuntimeException failure) { report(failure); }
}
}
} catch (IOException | RuntimeException failure) { report(failure); }
}); } catch (RuntimeException rejected) { report(rejected); }
}
private Path file(UUID session) { return pending.resolve(session + ".pending"); }
private SavedStart write(SavedStart value) throws IOException {
if (Files.exists(pending, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe switch event intents");
Files.createDirectories(pending);
Path target = file(value.capture().session());
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return read(target);
var fields = new Properties();
var capture = value.capture(); var exit = value.exit();
fields.setProperty("player", capture.player().toString());
fields.setProperty("player_name", capture.playerName());
fields.setProperty("character", capture.character().toString());
fields.setProperty("character_name", capture.characterName());
fields.setProperty("session", capture.session().toString());
fields.setProperty("reason", capture.reason());
fields.setProperty("exited_at", exit.exitedAt().toString());
fields.setProperty("deadline", exit.deadline().toString());
fields.setProperty("revision_before", value.revisionBefore());
fields.setProperty("revision_after", value.revisionAfter());
fields.setProperty("time", value.time().toString());
if (capture.location() != null) {
var location = capture.location();
fields.setProperty("world", location.worldId().toString());
fields.setProperty("world_name", location.worldName());
fields.setProperty("x", Double.toString(location.x()));
fields.setProperty("y", Double.toString(location.y()));
fields.setProperty("z", Double.toString(location.z()));
fields.setProperty("location_time", location.capturedAt().toString());
}
var output = new ByteArrayOutputStream();
fields.store(output, "acknowledged switch deadline");
byte[] bytes = output.toByteArray();
if (bytes.length > MAX_BYTES) throw new IOException("Switch event intent too large");
Path temporary = Files.createTempFile(pending, ".switch-", ".tmp");
try {
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) channel.write(buffer);
channel.force(true);
}
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
forceDirectory();
} finally { Files.deleteIfExists(temporary); }
return value;
}
private void forceDirectory() throws IOException {
try (var channel = FileChannel.open(pending, StandardOpenOption.READ)) { channel.force(true); }
}
private SavedStart read(Path path) throws IOException {
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe switch event intent");
var fields = new Properties();
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
var bytes = input.readNBytes(MAX_BYTES + 1);
if (bytes.length > MAX_BYTES) throw new IOException("Switch event intent too large");
fields.load(new java.io.ByteArrayInputStream(bytes));
}
try {
var player = UUID.fromString(fields.getProperty("player"));
var character = UUID.fromString(fields.getProperty("character"));
var session = UUID.fromString(fields.getProperty("session"));
if (!path.equals(file(session))) throw new IOException("Switch event identity mismatch");
var reason = fields.getProperty("reason");
if (!java.util.Set.of("voluntary", "logout", "defeat", "shutdown").contains(reason))
throw new IOException("Invalid switch exit reason");
EventInput.Location location = fields.containsKey("world")
? new EventInput.Location(UUID.fromString(fields.getProperty("world")), fields.getProperty("world_name"),
Double.parseDouble(fields.getProperty("x")), Double.parseDouble(fields.getProperty("y")),
Double.parseDouble(fields.getProperty("z")), Instant.parse(fields.getProperty("location_time")), "exact")
: null;
var before = fields.getProperty("revision_before"); var after = fields.getProperty("revision_after");
if (!before.matches("0|[1-9][0-9]*") || !after.matches("[1-9][0-9]*"))
throw new IOException("Invalid switch revision");
var exit = new EquipmentSessionStore.Exit(session, character,
Instant.parse(fields.getProperty("exited_at")), Instant.parse(fields.getProperty("deadline")));
return new SavedStart(new ExitContext(player, Objects.requireNonNull(fields.getProperty("player_name")),
character, Objects.requireNonNull(fields.getProperty("character_name")), session, location, reason),
exit, before, after, Instant.parse(fields.getProperty("time")));
} catch (RuntimeException invalid) { throw new IOException("Invalid switch event intent", invalid); }
}
private void report(Throwable failure) {
try { failures.accept(failure); } catch (RuntimeException ignored) { }
}
/** Shutdown does not wait for file I/O on the Minecraft thread. */
@Override public void close() {
worker.shutdown();
CompletableFuture.runAsync(() -> {
try {
if (!worker.awaitTermination(5, TimeUnit.SECONDS))
report(new IOException("Switch event intents did not drain"));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt(); report(interrupted);
} finally { drained.complete(null); }
});
}
public CompletionStage<Void> drained() { return drained; }
public void evaluated(Player player, UUID requested, EquipmentSessionStore.State state,
Duration configured, Instant now, boolean allowed) {
try {
var action = UUID.randomUUID();
var values = new LinkedHashMap<String, Object>();
values.put("action_kind", "character_switch_preflight");
values.put("requested_character_id", requested.toString());
values.put("configured_duration_seconds", Long.toString(configured.getSeconds()));
values.put("preflight_order", "before_equipment_pack");
values.put("outcome", "observed");
var exit = state == null ? null : state.lastExit();
boolean same = exit != null && requested.equals(exit.characterId());
values.put("same_character", same);
values.put("last_exited_character_id", exit == null ? "none" : exit.characterId().toString());
if (exit != null) {
values.put("deadline", exit.deadline().toString());
var remaining = Duration.between(now, exit.deadline());
values.put("remaining_seconds", Long.toString(Math.max(0,
remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0))));
values.put("receipt_revision", state.revision().toString());
}
var context = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
location(player.getLocation()));
emit(new EventInput(id(action, "observed"), action, clock.instant(), "action.observed", context, values));
values.put("outcome", allowed ? "allowed" : "refused");
values.put("reason", allowed ? (same ? "same_character" : "prepack_allowed") : "cooldown");
emit(new EventInput(id(action, "resolved"), action, clock.instant(), "action.resolved", context, values));
} catch (RuntimeException ignored) { /* Logging cannot change entry admission. */ }
}
private EventInput.Location location(Location value) {
return value == null || value.getWorld() == null ? null
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
value.getZ(), clock.instant(), "exact");
}
private void emit(EventInput event) {
try { sink.apply(event); } catch (RuntimeException ignored) { }
}
private static UUID id(UUID action, String kind) {
return UUID.nameUUIDFromBytes(("fantasystick:switch-delay:" + action + ':' + kind)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -77,6 +77,41 @@ public final class EquipmentStorage {
return new PackPlan(PackResult.PACKED, item, available);
}
/** Bounded custody/slot-class proof; never exposes the box's items or metadata to event payloads. */
public record Snapshot(String boxSource, int boxSlot, int hotbarSlots, int armorSlots, boolean displacedHead) {
public int packedSlots() { return hotbarSlots + armorSlots; }
public java.util.List<String> slotClasses() {
var result = new java.util.ArrayList<String>(2);
if (hotbarSlots > 0) result.add("hotbar");
if (armorSlots > 0) result.add("armor");
return java.util.List.copyOf(result);
}
}
/** Read-only native proof at the packing/restoration boundary, including moved or cursor custody. */
public java.util.Optional<Snapshot> snapshot(PlayerInventory inventory, UUID owner, UUID session, ItemStack cursor) {
Snapshot found = null;
for (int slot = 9; slot <= 41; slot++) {
if (slot >= 36 && slot < 40) continue;
var item = slot == 41 ? cursor : inventory.getItem(slot);
if (!matches(item, owner, session)) continue;
if (found != null || item.getAmount() != 1 || !(item.getItemMeta() instanceof BlockStateMeta meta)
|| !(meta.getBlockState() instanceof ShulkerBox box)) return java.util.Optional.empty();
var fields = meta.getPersistentDataContainer();
if (!fields.has(VERSION, PersistentDataType.INTEGER) || fields.get(VERSION, PersistentDataType.INTEGER) != 1
|| !Arrays.equals(EQUIPMENT_SLOTS, fields.get(SLOTS, PersistentDataType.INTEGER_ARRAY)))
return java.util.Optional.empty();
var contents = box.getInventory();
int hotbar = 0, armor = 0;
for (int index = 0; index < EQUIPMENT_SLOTS.length; index++) {
if (!empty(contents.getItem(index))) { if (index < 9) hotbar++; else armor++; }
}
found = new Snapshot(slot == 41 ? "cursor" : slot == 40 ? "offhand" : "main",
slot, hotbar, armor, !empty(inventory.getItem(39)));
}
return java.util.Optional.ofNullable(found);
}
public enum RestoreResult { RESTORED, NOT_FOUND, AMBIGUOUS, INVALID_BOX, OCCUPIED_SLOT }
/** Caller removes validated fantasy controls first. Unexpected slot occupants are never overwritten. */
@@ -5,6 +5,7 @@ import java.util.Objects;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import java.util.WeakHashMap;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
@@ -17,12 +18,24 @@ public final class PartyCombat implements Listener, AutoCloseable {
private boolean closed;
private final PartyRoster parties;
private final CombatSources sources;
private games.dmg.fantasystick.events.PartyProtectionEventBridge protectionEvents;
private final WeakHashMap<EntityDamageEvent, DamageAudit> damageAudits = new WeakHashMap<>();
private final WeakHashMap<org.bukkit.event.Event, NativeAudit> nativeAudits = new WeakHashMap<>();
private record NativeAudit(games.dmg.fantasystick.events.PartyProtectionEventBridge.Action action,
String reason) { }
private record DamageAudit(games.dmg.fantasystick.events.PartyProtectionEventBridge.Action action,
String decision) { }
public PartyCombat(PartyRoster parties, EquipmentSessions sessions) {
this.parties = Objects.requireNonNull(parties);
this.sources = new CombatSources(sessions);
}
public void attachProtectionEvents(games.dmg.fantasystick.events.PartyProtectionEventBridge events) {
if (protectionEvents != null || closed) throw new IllegalStateException("Party protection events already attached");
protectionEvents = Objects.requireNonNull(events);
}
public void install(org.bukkit.plugin.Plugin plugin) {
cleanup = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
if (!closed) for (var player : plugin.getServer().getOnlinePlayers()) OngoingDamage.sweep(player);
@@ -31,6 +44,8 @@ public final class PartyCombat implements Listener, AutoCloseable {
@Override public void close() {
closed = true;
damageAudits.clear();
nativeAudits.clear();
if (cleanup != null) cleanup.cancel();
}
@@ -40,8 +55,21 @@ public final class PartyCombat implements Listener, AutoCloseable {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void impact(org.bukkit.event.entity.ProjectileHitEvent event) {
var origin = sources.read(event.getEntity());
if (origin != null && (!sources.current(origin) || !(event.getEntity() instanceof org.bukkit.entity.ThrownPotion)
&& event.getHitEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
if (origin == null) return;
var target = event.getHitEntity() instanceof Player player ? player : null;
var reason = !sources.current(origin) ? "stale_source"
: target != null && !(event.getEntity() instanceof org.bukkit.entity.ThrownPotion)
? protectionReason(origin, target) : null;
if (reason != null) {
captureNative(event, "party_projectile_impact_protection", event.getEntity(), target, origin,
reason, "projectile_impact");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedImpact(org.bukkit.event.entity.ProjectileHitEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -50,13 +78,33 @@ public final class PartyCombat implements Listener, AutoCloseable {
// Revoke stale continuation here. Valid healing and unrelated recipients are untouched;
// actual harmful applications and instantaneous damage have their own guards below.
var origin = sources.read(event.getEntity());
if (origin != null && !sources.current(origin)) event.setCancelled(true);
if (origin != null && !sources.current(origin)) {
var recipients = event.getAffectedEntities().stream().filter(Player.class::isInstance)
.map(Player.class::cast).toList();
captureNative(event, "party_splash_protection", event.getEntity(),
recipients.size() == 1 ? recipients.getFirst() : null, origin, "stale_source", "potion_splash");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedSplash(org.bukkit.event.entity.PotionSplashEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void staleLingering(LingeringPotionSplashEvent event) {
var origin = sources.read(event.getEntity());
if (origin != null && !sources.current(origin)) event.setCancelled(true);
if (origin != null && !sources.current(origin)) {
captureNative(event, "party_lingering_protection", event.getEntity(), null,
origin, "stale_source", "lingering_splash");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedLingering(LingeringPotionSplashEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -64,12 +112,49 @@ public final class PartyCombat implements Listener, AutoCloseable {
sources.store(event.getAreaEffectCloud(), sources.read(event.getEntity()));
}
@EventHandler(priority = EventPriority.LOWEST)
public void observeDamage(EntityDamageEvent event) {
if (protectionEvents == null || !(event.getEntity() instanceof Player target)) return;
// OngoingDamage.read reconciles native PDC. Only the original HIGHEST guard may do so;
// the LOWEST audit must not change its timing or mutate an earlier protection's state.
if (event.getCause() == EntityDamageEvent.DamageCause.POISON
|| event.getCause() == EntityDamageEvent.DamageCause.WITHER
|| event.getCause() == EntityDamageEvent.DamageCause.FIRE_TICK) return;
var origin = sources.source(event);
if (origin == null) return;
var ongoing = OngoingDamage.read(target, event.getCause());
String reason = ongoing != null && !sources.current(ongoing.target()) ? "stale_target"
: protectionReason(origin, target);
if (reason == null) return;
captureDamage(event, target, origin, reason);
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedDamage(EntityDamageEvent event) {
if (protectionEvents == null) return;
var audit = damageAudits.remove(event);
if (audit == null) return;
boolean prior = Boolean.TRUE.equals(audit.action().data().get("prior_cancelled"));
String reason = prior && event.isCancelled() ? "prior_cancelled"
: !event.isCancelled() && audit.decision() != null ? "protection_overridden"
: event.isCancelled() && audit.decision() != null ? audit.decision()
: event.isCancelled() ? "external_cancelled" : "protection_not_applied";
String outcome = !event.isCancelled() ? "accepted" : prior || audit.decision() == null
? "cancelled" : "refused";
protectionEvents.resolved(audit.action(), outcome, reason, event.isCancelled(),
event.isCancelled() ? 0 : event.getFinalDamage());
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void damage(EntityDamageEvent event) {
if (games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()) return;
if (event.getEntity() instanceof Player target) {
var ongoing = OngoingDamage.read(target, event.getCause());
if (ongoing != null && (!sources.current(ongoing.target()) || blocked(ongoing.source(), target))) {
var reason = !sources.current(ongoing.target()) ? "stale_target"
: protectionReason(ongoing.source(), target);
captureDamage(event, target, ongoing.source(), reason);
markDamageDecision(event, reason);
event.setCancelled(true); return;
}
}
@@ -77,7 +162,14 @@ public final class PartyCombat implements Listener, AutoCloseable {
if (origin == null && event instanceof EntityDamageByEntityEvent entity) origin = sources.read(entity.getDamager());
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
if (origin != null && (!sources.current(origin)
|| event.getEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
|| event.getEntity() instanceof Player target && blocked(origin, target))) {
if (event.getEntity() instanceof Player target) {
var reason = protectionReason(origin, target);
captureDamage(event, target, origin, reason);
markDamageDecision(event, reason);
}
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -85,7 +177,16 @@ public final class PartyCombat implements Listener, AutoCloseable {
if (!(event.getEntity() instanceof Player target) || event.getNewEffect() == null
|| OngoingDamage.cause(event.getModifiedType()) == null) return;
var origin = sources.read(event.getSource());
if (origin != null && blocked(origin, target)) event.setCancelled(true);
if (origin != null && blocked(origin, target)) {
captureNative(event, "party_effect_application_protection", event.getSource(), target,
origin, protectionReason(origin, target), event.getModifiedType().getKey().getKey());
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedPotion(org.bukkit.event.entity.EntityPotionEffectEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -105,7 +206,16 @@ public final class PartyCombat implements Listener, AutoCloseable {
public void combust(org.bukkit.event.entity.EntityCombustByEntityEvent event) {
if (!(event.getEntity() instanceof Player target)) return;
var origin = sources.read(event.getCombuster());
if (origin != null && blocked(origin, target)) event.setCancelled(true);
if (origin != null && blocked(origin, target)) {
captureNative(event, "party_ignite_protection", event.getCombuster(), target,
origin, protectionReason(origin, target), "combust");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedCombust(org.bukkit.event.entity.EntityCombustByEntityEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -118,11 +228,49 @@ public final class PartyCombat implements Listener, AutoCloseable {
: new OngoingDamage.Application(origin, sources.player(target.getUniqueId())));
}
private boolean blocked(CombatSources.Origin origin, Player target) {
private void captureNative(org.bukkit.event.Event event, String kind, org.bukkit.entity.Entity causing,
Player target, CombatSources.Origin origin, String reason, String nativeKind) {
if (protectionEvents == null || reason == null) return;
var recipient = target == null ? null : sources.player(target.getUniqueId());
var action = protectionEvents.nativeAttempt(kind, causing, target, origin, recipient,
parties.partyOf(origin.player()).orElse(null),
recipient == null ? null : parties.partyOf(recipient.player()).orElse(null),
reason, nativeKind);
if (action != null) nativeAudits.put(event, new NativeAudit(action, reason));
}
private void resolveNative(org.bukkit.event.Event event, org.bukkit.event.Cancellable cancellable) {
if (protectionEvents == null) return;
var audit = nativeAudits.remove(event);
if (audit != null) protectionEvents.resolved(audit.action(), cancellable.isCancelled() ? "refused" : "accepted",
cancellable.isCancelled() ? audit.reason() : "external_uncancelled", cancellable.isCancelled(), null);
}
private void captureDamage(EntityDamageEvent event, Player target, CombatSources.Origin origin, String reason) {
if (protectionEvents == null || damageAudits.containsKey(event) || reason == null) return;
var recipient = sources.player(target.getUniqueId());
var action = protectionEvents.damage(event, origin, recipient, target,
parties.partyOf(origin.player()).orElse(null), parties.partyOf(recipient.player()).orElse(null), reason);
if (action != null) damageAudits.put(event, new DamageAudit(action, null));
}
private void markDamageDecision(EntityDamageEvent event, String reason) {
var audit = damageAudits.get(event);
if (audit != null && reason != null) damageAudits.put(event, new DamageAudit(audit.action(), reason));
}
private String protectionReason(CombatSources.Origin origin, Player target) {
if (!sources.current(origin)) return "stale_source";
var recipient = sources.player(target.getUniqueId());
boolean fantasy = origin.session() != null;
return !sources.current(origin) || fantasy != (recipient.session() != null) || fantasy
&& !origin.player().equals(recipient.player())
&& parties.partyOf(origin.player()).filter(group -> group.members().contains(recipient.player())).isPresent();
if (fantasy != (recipient.session() != null)) return "mixed_mode";
if (fantasy && !origin.player().equals(recipient.player())
&& parties.partyOf(origin.player()).filter(group -> group.members().contains(recipient.player())).isPresent())
return "same_party";
return null;
}
private boolean blocked(CombatSources.Origin origin, Player target) {
return protectionReason(origin, target) != null;
}
}
@@ -30,6 +30,9 @@ public final class AdventureSettings implements AutoCloseable {
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-configuration-persistence"));
private volatile boolean enabled;
private volatile String revision;
public java.util.Optional<String> revision() { return java.util.Optional.ofNullable(revision); }
public record Saved(boolean beforeValue, boolean value, String revision) { }
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
@@ -53,13 +56,32 @@ public final class AdventureSettings implements AutoCloseable {
public boolean enabled() { return enabled; }
public synchronized CompletionStage<Boolean> set(boolean value) {
public CompletionStage<Boolean> set(boolean value) {
return setRecorded(value).thenApply(Saved::value);
}
/** The digest identifies the exact acknowledged config bytes, not a cross-file transaction. */
public CompletionStage<Saved> setRecorded(boolean value) {
return setRecorded(value, ignored -> { });
}
/** Capture an acknowledged audit intent before the configuration worker finishes draining. */
public synchronized CompletionStage<Saved> setRecorded(boolean value, java.util.function.Consumer<Saved> onSaved) {
Objects.requireNonNull(onSaved);
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
try {
return CompletableFuture.supplyAsync(() -> {
try { save(value); enabled = value; return value; }
catch (IOException | RuntimeException failure) {
try {
boolean before = enabled;
var revision = save(value);
enabled = value;
this.revision = revision;
var saved = new Saved(before, value, revision);
try { onSaved.accept(saved); }
catch (RuntimeException ignored) { /* An audit observer cannot roll back an acknowledged policy. */ }
return saved;
} catch (IOException | RuntimeException failure) {
// No parser contents, filesystem paths or configuration values in diagnostics.
throw new java.util.concurrent.CompletionException(new IOException(
"Adventure configuration save refused or failed; effective setting unchanged"));
@@ -71,7 +93,7 @@ public final class AdventureSettings implements AutoCloseable {
}
}
private void save(boolean value) throws IOException {
private String save(boolean value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
@@ -111,7 +133,15 @@ public final class AdventureSettings implements AutoCloseable {
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
String revision;
try {
revision = "sha256:" + java.util.HexFormat.of().formatHex(
java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (java.security.NoSuchAlgorithmException missing) {
throw new IllegalStateException("SHA-256 unavailable", missing);
}
replacement.move(temporary, file);
return revision;
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
@@ -370,20 +370,29 @@ public final class CharacterStorage implements AutoCloseable {
public CompletableFuture<Optional<CharacterRecord>> renameCharacter(
UUID characterId, String name, BigInteger expectedSheetGeneration) {
return renameCharacterObserved(characterId, name, expectedSheetGeneration).thenApply(RenameOutcome::record);
}
/** Capture the authoritative before/after pair in the same serialized save, not from stale item text. */
public record RenameOutcome(Optional<CharacterRecord> record, Optional<CharacterRecord> before, String status) { }
public CompletableFuture<RenameOutcome> renameCharacterObserved(
UUID characterId, String name, BigInteger expectedSheetGeneration) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(name);
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
if (found.isEmpty() || found.orElseThrow().dead())
return new RenameOutcome(Optional.empty(), Optional.empty(), "unavailable");
var old = found.orElseThrow();
if (expectedSheetGeneration != null && !old.sheetGeneration().equals(expectedSheetGeneration))
return Optional.empty();
if (old.name().equals(name)) return Optional.of(old);
return new RenameOutcome(Optional.empty(), Optional.of(old), "stale_generation");
if (old.name().equals(name)) return new RenameOutcome(Optional.of(old), Optional.of(old), "unchanged");
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(renamed, old.revision());
return Optional.of(renamed);
return new RenameOutcome(Optional.of(renamed), Optional.of(old), "renamed");
});
}
@@ -882,6 +891,19 @@ public final class CharacterStorage implements AutoCloseable {
}
}
/** Observe accepted save callbacks without waiting for file I/O on the native thread. */
public java.util.concurrent.CompletionStage<Void> drained() {
return java.util.concurrent.CompletableFuture.runAsync(() -> {
try {
if (!worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS))
throw new java.util.concurrent.CompletionException(new java.io.IOException("Character storage did not drain"));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new java.util.concurrent.CompletionException(interrupted);
}
});
}
/** Stop accepting work without blocking shutdown; already queued file writes may finish. */
@Override
public void 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;
}
}
}
@@ -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;
@@ -109,6 +111,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.book.CharacterBookController characterBooks;
private games.dmg.fantasystick.persistence.CharacterStorage characters;
private AdminCharacterRecovery characterRecovery;
private games.dmg.fantasystick.events.SessionRecoveryEventBridge sessionRecoveryEvents;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -400,6 +403,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
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");
@@ -412,6 +420,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
characterRecovery = Objects.requireNonNull(recovery);
}
public void attachSessionRecoveryEvents(games.dmg.fantasystick.events.SessionRecoveryEventBridge events) {
if (closed || sessionRecoveryEvents != null) throw new IllegalStateException("Session recovery events already attached or command closed");
sessionRecoveryEvents = Objects.requireNonNull(events);
}
public void attachPartyEvents(games.dmg.fantasystick.events.PartyEventBridge events){if(closed||partyEvents!=null)throw new IllegalStateException("Party events already attached or command closed");partyEvents=Objects.requireNonNull(events);}
public void attachPartyRegistrars(games.dmg.fantasystick.party.PartyRegistrarDirectory directory,
@@ -420,6 +433,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");
@@ -441,6 +460,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (closed) return true;
if (!sender.hasPermission(PERMISSION)) {
if (sessionRecoveryEvents != null && args.length > 0
&& (args[0].equals("inspect") || args[0].equals("recover"))) {
var action = sessionRecoveryEvents.capture(sender, null);
var kind = args[0].equals("inspect") ? "equipment_inspect" : "equipment_recovery";
sessionRecoveryEvents.observed(action, kind);
sessionRecoveryEvents.resolved(action, kind, "refused", "unauthorized");
}
sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");
return true;
}
@@ -497,24 +523,41 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
return true;
}
if (args[0].equals("recover")) {
var request = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, owner);
if (sessionRecoveryEvents != null) sessionRecoveryEvents.observed(request, "equipment_recovery");
var player = server.getPlayer(owner);
if (player == null || !player.isOnline()) {
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(request,
"equipment_recovery", "refused", "target_offline");
sender.sendMessage("Recovery refused: target " + owner + " must be online. No items changed.");
return true;
}
sessions.recover(player);
sessions.recover(player, request);
sender.sendMessage("Validated equipment recovery requested for " + owner
+ ". This is not confirmation of restoration; ambiguous items and receipts are retained.");
return true;
}
var inspection = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, owner);
if (sessionRecoveryEvents != null) sessionRecoveryEvents.observed(inspection, "equipment_inspect");
receipts.find(owner).whenComplete((state, failure) -> {
if (failure != null) report(owner, "inspect", failure);
if (closed) return;
try {
nextTick.accept(() -> {
if (closed || !sender.hasPermission(PERMISSION)) return;
if (sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
if (closed) return;
if (!sender.hasPermission(PERMISSION)) {
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(inspection,
"equipment_inspect", "refused", "permission_changed");
return;
}
if (sender instanceof org.bukkit.entity.Player player && !player.isOnline()) {
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(inspection,
"equipment_inspect", "cancelled", "sender_offline");
return;
}
if (failure != null) {
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(inspection,
"equipment_inspect", "failed", "receipt_unavailable");
sender.sendMessage("Equipment inspection failed for " + owner + "; retained state requires inspection. See server diagnostics.");
return;
}
@@ -530,9 +573,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
+ " character=" + exit.characterId() + " exited=" + exit.exitedAt() + " deadline=" + exit.deadline());
}
var player = server.getPlayer(owner);
var marker = player == null || !player.isOnline()
? java.util.Optional.<EquipmentSessionMarker.Token>empty() : EquipmentSessionMarker.read(player);
if (sessionRecoveryEvents != null) {
sessionRecoveryEvents.inspected(inspection, state,
marker.map(token -> token.phase().name().toLowerCase(java.util.Locale.ROOT))
.orElse(player == null || !player.isOnline() ? "offline_unavailable" :
EquipmentSessionMarker.present(player) ? "malformed" : "absent"),
player != null && player.isOnline());
sessionRecoveryEvents.resolved(inspection, "equipment_inspect", "accepted", "read_completed");
}
if (player == null || !player.isOnline()) sender.sendMessage("Native state: offline; not inspected.");
else {
var marker = EquipmentSessionMarker.read(player);
sender.sendMessage("Online native marker=" + marker.map(token -> "session=" + token.session()
+ " character=" + token.character() + " phase=" + token.phase()).orElseGet(() ->
EquipmentSessionMarker.present(player) ? "malformed (retained)" : "none"));
@@ -912,7 +964,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) {
@@ -1011,12 +1063,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;
@@ -1030,21 +1088,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);
@@ -1199,8 +1292,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;
@@ -1214,16 +1310,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); }
});
@@ -1231,8 +1329,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;
@@ -1246,21 +1347,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];
@@ -1544,19 +1680,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; }
@@ -1565,22 +1729,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]); }
@@ -1590,22 +1761,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;
@@ -1619,15 +1797,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); }
});
@@ -1686,22 +1867,39 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
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 adventureConfiguration(CommandSender sender, String[] args) {
var action = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, null);
if (sessionRecoveryEvents != null) sessionRecoveryEvents.observed(action, "adventure_mode_config");
if (args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(action,
"adventure_mode_config", "refused", "invalid_value");
sender.sendMessage("Usage: /fstickadmin config adventure-mode [true|false]"); return;
}
if (settings == null) { sender.sendMessage("Adventure configuration is unavailable."); return; }
if (settings == null) {
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(action,
"adventure_mode_config", "failed", "settings_unavailable");
sender.sendMessage("Adventure configuration is unavailable."); return;
}
if (args.length == 2) {
if (sessionRecoveryEvents != null) {
sessionRecoveryEvents.configRead(action, settings.enabled(), settings.revision());
sessionRecoveryEvents.resolved(action, "adventure_mode_config", "accepted", "read_completed");
}
sender.sendMessage("adventure-mode=" + settings.enabled() + " (applies to new sessions only)"); return;
}
settings.set(Boolean.parseBoolean(args[2])).whenComplete((saved, failure) -> {
settings.setRecorded(Boolean.parseBoolean(args[2]), saved -> {
if (sessionRecoveryEvents != null) sessionRecoveryEvents.configChanged(action, saved);
}).whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-adventure-mode", failure);
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(action, "adventure_mode_config",
failure == null ? "accepted" : "failed", failure == null ? "save_acknowledged" : "save_failed");
if (closed) return;
try {
nextTick.accept(() -> {
if (closed || !sender.hasPermission(PERMISSION)
if (closed) return;
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved adventure-mode=" + saved + " for new sessions only; active sessions are unchanged."
? "Saved adventure-mode=" + saved.value() + " for new sessions only; active sessions are unchanged."
: "Adventure configuration save failed; the effective setting is unchanged.");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
@@ -1789,6 +1987,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());
@@ -1853,6 +2052,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]);
@@ -166,7 +166,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
queued.remove(owner);
swaps.remove(owner);
boolean reserved = writing.add(owner);
try { sessions.invalidateContext(owner); sessions.exit(player); }
try { sessions.invalidateContext(owner); sessions.exit(player, "logout"); }
finally { if (reserved) writing.remove(owner); observed.remove(owner); }
}
@@ -48,6 +48,12 @@ 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;
private games.dmg.fantasystick.events.SessionRecoveryEventBridge sessionRecoveryEvents;
private java.util.function.Function<DownedEpisode,CompletionStage<?>> downedDefeatRewards=ignored->CompletableFuture.completedStage(null);
private java.util.function.Predicate<UUID> unavailableCharacter = ignored -> false;
private java.util.function.Function<UUID,java.util.concurrent.CompletionStage<?>> partyDefeat =
@@ -106,6 +112,7 @@ public final class EquipmentSessions implements AutoCloseable {
games.dmg.fantasystick.persistence.OrdinaryVitals ordinaryVitals;
boolean vitalsStarted;
boolean healthSaveRequested;
boolean healthSaveFailed;
boolean healthAcknowledged = true;
boolean nativeRestored;
boolean gearRestoredForModeRecovery;
@@ -117,6 +124,25 @@ 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";
java.math.BigInteger packReceiptRevision;
games.dmg.fantasystick.events.EquipmentEventBridge.Capture restoreEvent;
games.dmg.fantasystick.events.EquipmentEventBridge.Action restoreAction;
games.dmg.fantasystick.events.EquipmentEventBridge.Action recoveryAction;
games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action sessionRecoveryAction;
games.dmg.fantasystick.events.EventInput.Location recoveryOriginalLocation;
games.dmg.fantasystick.events.EventInput.Location recoveryCurrentLocation;
String recoveryPriorPhase = "unavailable";
java.math.BigInteger recoveryReceiptRevision;
boolean recoveryResolved;
boolean sessionRecoveryResolved;
boolean restoreEventWritten;
Duration exitDelay;
boolean arrival;
Attempt recoveryOwner;
@@ -210,6 +236,12 @@ 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);}
public synchronized void attachSessionRecoveryEvents(games.dmg.fantasystick.events.SessionRecoveryEventBridge events){if(!owned.isEmpty()||sessionRecoveryEvents!=null)throw new IllegalStateException("Recovery events already attached or sessions started");sessionRecoveryEvents=Objects.requireNonNull(events);}
public synchronized void attachDownedDefeatRewards(java.util.function.Function<DownedEpisode,CompletionStage<?>> settlement){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedDefeatRewards=Objects.requireNonNull(settlement);}
public synchronized List<DownedEpisode.Reward> finalRewards(DownedEpisode episode){return List.copyOf(downedRewardAllocation.apply(Objects.requireNonNull(episode)));}
@@ -610,19 +642,29 @@ public final class EquipmentSessions implements AutoCloseable {
refuse(attempt, "RECOVERY_REQUIRED", "An unresolved equipment receipt requires validated recovery."); return;
}
Instant now = clock.instant();
if (!state.canEnter(attempt.character, switchDelay.get(), now)) {
boolean admitted = state.canEnter(attempt.character, switchDelay.get(), now);
if (switchEvents != null) switchEvents.evaluated(attempt.player, attempt.character,
state, switchDelay.get(), now, admitted);
if (!admitted) {
Duration remaining = Duration.between(now, state.lastExit().deadline());
long seconds = remaining.getSeconds() + (remaining.getNano() == 0 ? 0 : 1);
refuse(attempt, "COOLDOWN", "Wait " + seconds
+ " seconds to switch characters; the same character may be resumed now."); return;
}
}
} else if (switchEvents != null) switchEvents.evaluated(attempt.player, attempt.character,
null, switchDelay.get(), clock.instant(), true);
if (!entryNativeSafe(attempt, loaded.state().orElse(null))) {
refuse(attempt, "NATIVE_RECOVERY_REQUIRED", "Native equipment marker or control/box remnants require inspection; entry did not change items.");
return;
}
var refusal = equipment.refusal(attempt.player.getInventory(), attempt.owner, UUID.randomUUID());
if (refusal.isPresent()) { refuse(attempt, refusal.orElseThrow().name(), "Equipment entry refused: " + refusal.orElseThrow()); return; }
if (refusal.isPresent()) {
if (equipmentEvents != null) {
var action = equipmentEvents.action(attempt.player, null, attempt.character, "equipment_pack_preflight");
equipmentEvents.resolved(action, "refused", refusal.orElseThrow().name().toLowerCase(java.util.Locale.ROOT));
}
refuse(attempt, refusal.orElseThrow().name(), "Equipment entry refused: " + refusal.orElseThrow()); return;
}
// Restart receipts can belong to dead/missing characters, so they cannot acquire a
// live SessionClaims token. Their unresolved equipment outcome still excludes entry.
if (owned.values().stream().anyMatch(other -> attempt.character.equals(other.character))) {
@@ -639,6 +681,7 @@ public final class EquipmentSessions implements AutoCloseable {
attempt.saving = false;
if (error != null) { attempt.uncertainSave = true; fail(attempt, error); return; }
attempt.acknowledged = true;
attempt.packReceiptRevision = saved.revision();
if (!current(attempt)) { abandon(attempt); return; }
// Queue the cross-worker read under lifecycle ownership: close cannot finish
// its drain boundary and then have this callback submit fresh character IO.
@@ -687,6 +730,11 @@ public final class EquipmentSessions implements AutoCloseable {
}
if(gear!=null)gear.install(attempt.player.getInventory(),record,attempt.owner,attempt.session);
mark(attempt, EquipmentSessionMarker.Phase.PACKED);
if (equipmentEvents != null) try {
equipment.snapshot(attempt.player.getInventory(), attempt.owner, attempt.session,
attempt.player.getItemOnCursor()).ifPresent(box -> equipmentEvents.packed(
equipmentEvents.capture(attempt.player, attempt.session, record, box), attempt.packReceiptRevision));
} catch (RuntimeException observationFailure) { diagnostic(attempt, observationFailure); }
if (vitals != null) {
attempt.vitalsStarted = true;
attempt.healthAcknowledged = false;
@@ -708,6 +756,9 @@ 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.
notice(attempt.owner, "NATIVE_UNCERTAIN", "Equipment mutation failed; items and receipt require inspection.");
@@ -737,9 +788,14 @@ public final class EquipmentSessions implements AutoCloseable {
* A native exception returns INVALID_BOX with NATIVE_UNCERTAIN notice/diagnostic; never retry it.
*/
public synchronized EquipmentStorage.RestoreResult exit(Player player) {
return exit(player, "voluntary");
}
public synchronized EquipmentStorage.RestoreResult exit(Player player, String reason) {
UUID owner = player.getUniqueId();
var transition = transitioning.get(owner);
if (transition != null) {
transition.exitReason = Objects.requireNonNull(reason);
captureExitDelay(transition);
active.remove(owner, transition);
withdrawPresentation(transition);
@@ -748,8 +804,11 @@ 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);
captureExitDelay(attempt);
return restoreExit(attempt);
}
@@ -785,6 +844,7 @@ public final class EquipmentSessions implements AutoCloseable {
}
withdrawPresentation(attempt);
attempt.defeated = true;
attempt.exitReason = "defeat";
event.setCancelled(true);
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
// Submit first: any subsequent native getter/setter/notice can reenter disable, which
@@ -854,7 +914,7 @@ public final class EquipmentSessions implements AutoCloseable {
public synchronized void reviveDowned(DownedEpisode episode,Consumer<Boolean> completion){Objects.requireNonNull(episode);Objects.requireNonNull(completion);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.REVIVED){completion.accept(false);return;}var attempt=active.get(episode.player());if(attempt==null||attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character())){completion.accept(false);return;}var expected=attempt.record.revision();var maximum=attempt.record.maximumHealth().min(NativeHealthLimit.read());characters.saveHealth(attempt.character,expected,maximum,maximum).whenComplete((saved,failure)->persistReturn(()->{synchronized(EquipmentSessions.this){if(failure!=null||saved==null||saved.isEmpty()||active.get(attempt.owner)!=attempt||attempt.downed==null||!attempt.downed.id().equals(episode.id())){if(failure!=null)diagnostic(attempt,failure);completion.accept(false);return;}var record=saved.orElseThrow();try{if(vitals!=null&&!record.revision().equals(attempt.record.revision()))vitals.updateProgress(attempt.player,attempt.session,attempt.record,record);attempt.record=record;attempt.downed=null;attempt.downing=false;attempt.downedProtectionUntil=episode.protectionUntil();double nativeMaximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(Math.min(nativeMaximum,record.health().doubleValue()));refreshControls(attempt,new Active(attempt.owner,attempt.session,attempt.character));attempt.player.closeInventory();notice(attempt.owner,"REVIVED","§aRevive restored your downed character.");completion.accept(true);}catch(RuntimeException publicationFailure){diagnostic(attempt,publicationFailure);completion.accept(false);}}}));}
/** Publish one already-durable final-death outcome through the existing restoration/Tail custody path. */
public synchronized boolean finalizeDowned(DownedEpisode episode){Objects.requireNonNull(episode);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.FINAL_DEATH)return false;var attempt=active.get(episode.player());if(attempt==null){persist(()->recordOfflineFinalDeath(episode),(dead,failure)->{if(failure!=null)diagnostic(new Failure(episode.player(),episode.session(),failure));else if(dead.isEmpty())diagnostic(new Failure(episode.player(),episode.session(),new IllegalStateException("Offline final death conflicted with saved character")));});return true;}if(attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character()))return false;active.remove(attempt.owner,attempt);attempt.finalDowned=episode;attempt.downed=null;withdrawPresentation(attempt);attempt.defeated=true;boolean reserved=transitioning.putIfAbsent(attempt.owner,attempt)==null;boolean loseCoins=episode.attribution().playerDefeat();persist(()->characters.recordFinalDeath(attempt.character,attempt.record.revision(),episode.id(),episode.resolvedAt(),loseCoins),(dead,failure)->{if(failure!=null){diagnostic(attempt,failure);conversionUncertain(attempt,"Final death save failed or is uncertain");return;}if(dead.isEmpty()){conversionUncertain(attempt,"Character was missing or final death conflicted; no Tail authorized");return;}attempt.deathResolved=true;attempt.deathRecord=dead.orElseThrow();if(downedEvents!=null)downedEvents.finalDeathRecorded(episode);if(closed){conversionUncertain(attempt,"Shutdown before reward, party defeat and Tail conversion");return;}java.util.concurrent.CompletionStage<?> partyMutation=finalConsequences(episode);partyMutation.whenComplete((ignored,partyFailure)->{synchronized(EquipmentSessions.this){if(partyFailure!=null){diagnostic(attempt,partyFailure);conversionUncertain(attempt,"Party defeat mutation failed");return;}attempt.partyDefeatAcknowledged=true;queueTail(attempt);}});});try{var restored=restoreExit(attempt);if(restored==EquipmentStorage.RestoreResult.RESTORED){var retained=attempt.player.getInventory().getItemInOffHand();attempt.deathReference=copy(retained);attempt.deathNativeReference=nativeIdentity(retained);attempt.deathView=attempt.player.getOpenInventory();}double maximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(maximum);notice(attempt.owner,"DEFEAT","§cYour character was defeated.");attempt.defeatReturnTeleport=true;try{boolean returned;try{returned=attempt.player.teleport(attempt.entryLocation.clone());}catch(RuntimeException uncertain){if(downedEvents!=null)downedEvents.returnAttempted(episode,"uncertain");throw uncertain;}if(downedEvents!=null)downedEvents.returnAttempted(episode,returned?"returned":"refused");if(!returned)notice(attempt.owner,"DEFEAT_RETURN_FAILED","Return to the session entry location was refused; you remain here.");}finally{attempt.defeatReturnTeleport=false;if(!attempt.deliveryInvalidated&&owned.get(attempt.owner)==attempt&&attempt.player.isOnline())attempt.deathView=attempt.player.getOpenInventory();}}catch(RuntimeException failure){diagnostic(attempt,failure);conversionUncertain(attempt,"Native final-death transition failed; inspect retained equipment and death state");}finally{if(reserved)transitioning.remove(attempt.owner,attempt);finishDrain();}return true;}
public synchronized boolean finalizeDowned(DownedEpisode episode){Objects.requireNonNull(episode);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.FINAL_DEATH)return false;var attempt=active.get(episode.player());if(attempt==null){persist(()->recordOfflineFinalDeath(episode),(dead,failure)->{if(failure!=null)diagnostic(new Failure(episode.player(),episode.session(),failure));else if(dead.isEmpty())diagnostic(new Failure(episode.player(),episode.session(),new IllegalStateException("Offline final death conflicted with saved character")));});return true;}if(attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character()))return false;active.remove(attempt.owner,attempt);attempt.finalDowned=episode;attempt.downed=null;withdrawPresentation(attempt);attempt.defeated=true;attempt.exitReason="defeat";boolean reserved=transitioning.putIfAbsent(attempt.owner,attempt)==null;boolean loseCoins=episode.attribution().playerDefeat();persist(()->characters.recordFinalDeath(attempt.character,attempt.record.revision(),episode.id(),episode.resolvedAt(),loseCoins),(dead,failure)->{if(failure!=null){diagnostic(attempt,failure);conversionUncertain(attempt,"Final death save failed or is uncertain");return;}if(dead.isEmpty()){conversionUncertain(attempt,"Character was missing or final death conflicted; no Tail authorized");return;}attempt.deathResolved=true;attempt.deathRecord=dead.orElseThrow();if(downedEvents!=null)downedEvents.finalDeathRecorded(episode);if(closed){conversionUncertain(attempt,"Shutdown before reward, party defeat and Tail conversion");return;}java.util.concurrent.CompletionStage<?> partyMutation=finalConsequences(episode);partyMutation.whenComplete((ignored,partyFailure)->{synchronized(EquipmentSessions.this){if(partyFailure!=null){diagnostic(attempt,partyFailure);conversionUncertain(attempt,"Party defeat mutation failed");return;}attempt.partyDefeatAcknowledged=true;queueTail(attempt);}});});try{var restored=restoreExit(attempt);if(restored==EquipmentStorage.RestoreResult.RESTORED){var retained=attempt.player.getInventory().getItemInOffHand();attempt.deathReference=copy(retained);attempt.deathNativeReference=nativeIdentity(retained);attempt.deathView=attempt.player.getOpenInventory();}double maximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(maximum);notice(attempt.owner,"DEFEAT","§cYour character was defeated.");attempt.defeatReturnTeleport=true;try{boolean returned;try{returned=attempt.player.teleport(attempt.entryLocation.clone());}catch(RuntimeException uncertain){if(downedEvents!=null)downedEvents.returnAttempted(episode,"uncertain");throw uncertain;}if(downedEvents!=null)downedEvents.returnAttempted(episode,returned?"returned":"refused");if(!returned)notice(attempt.owner,"DEFEAT_RETURN_FAILED","Return to the session entry location was refused; you remain here.");}finally{attempt.defeatReturnTeleport=false;if(!attempt.deliveryInvalidated&&owned.get(attempt.owner)==attempt&&attempt.player.isOnline())attempt.deathView=attempt.player.getOpenInventory();}}catch(RuntimeException failure){diagnostic(attempt,failure);conversionUncertain(attempt,"Native final-death transition failed; inspect retained equipment and death state");}finally{if(reserved)transitioning.remove(attempt.owner,attempt);finishDrain();}return true;}
private CompletionStage<Void> finalConsequences(DownedEpisode episode){var result=new CompletableFuture<Void>();try{nextTick.accept(()->{CompletionStage<?> work;try{work=downedDefeatRewards.apply(episode).thenCompose(ignored->partyDefeat.apply(episode.character()));}catch(RuntimeException failure){result.completeExceptionally(failure);return;}work.whenComplete((ignored,failure)->{if(failure==null)result.complete(null);else result.completeExceptionally(failure);});});}catch(RuntimeException rejected){result.completeExceptionally(rejected);}return result;}
@@ -1008,9 +1068,12 @@ public final class EquipmentSessions implements AutoCloseable {
}
private EquipmentStorage.RestoreResult restoreExit(Attempt attempt) {
if(switchEvents!=null&&attempt.switchExit==null&&attempt.record!=null)
attempt.switchExit=switchEvents.exit(attempt.player,attempt.session,attempt.record,attempt.exitReason);
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;
@@ -1018,12 +1081,27 @@ public final class EquipmentSessions implements AutoCloseable {
invalidateCursor(attempt.owner);
saveRemainingHealth(attempt);
boolean modeRestored = restoreGameMode(attempt);
if (equipmentEvents != null) attempt.restoreAction = equipmentEvents.action(
attempt.player, attempt.session, attempt.character, "equipment_restore");
EquipmentStorage.RestoreResult result;
try {
result = restoreMarked(attempt);
} catch (RuntimeException failure) {
attempt.nativeUncertain = true;
if (equipmentEvents != null) {
equipmentEvents.resolved(attempt.restoreAction, "uncertain", "native_exception");
if (attempt.recoveryAction != null) {
equipmentEvents.resolved(attempt.recoveryAction, "uncertain", "native_exception");
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
attempt.recoveryPriorPhase, "restoring", "native_uncertain", "ambiguous",
"native_exception", attempt.recoveryReceiptRevision);
}
}
captureRecoveryLocations(attempt);
sessionReconciled(attempt, "restoring", "ambiguous", "native_uncertain",
"native_exception", attempt.recoveryReceiptRevision);
restoreVitals(attempt); // Proven health ownership is independent of uncertain equipment writes.
captureRecoveryLocations(attempt);
persistExit(attempt, false);
diagnostic(attempt, failure);
notice(attempt.owner, "NATIVE_UNCERTAIN", "Restoration failed; items and receipt require inspection.");
@@ -1033,10 +1111,28 @@ public final class EquipmentSessions implements AutoCloseable {
if (result == EquipmentStorage.RestoreResult.RESTORED && (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged)) {
attempt.gearRestoredForModeRecovery = true;
}
captureRecoveryLocations(attempt);
persistExit(attempt, result == EquipmentStorage.RestoreResult.RESTORED && modeRestored && vitalsRestored);
if (result != EquipmentStorage.RestoreResult.RESTORED) {
if (equipmentEvents != null) {
var reason = result.name().toLowerCase(java.util.Locale.ROOT);
equipmentEvents.resolved(attempt.restoreAction, "refused", reason);
if (attempt.recoveryAction != null) equipmentEvents.resolved(attempt.recoveryAction, "retained", reason);
if (attempt.recoveryAction != null) equipmentEvents.recovery(attempt.recoveryAction,
attempt.session, attempt.character, attempt.recoveryPriorPhase, "pending",
attempt.restoreEvent == null ? "receipt_and_marker_only" : "verified_box_and_marker",
"retained", reason, attempt.recoveryReceiptRevision);
}
sessionReconciled(attempt, "pending", "retained", "receipt_and_marker_only",
result.name().toLowerCase(java.util.Locale.ROOT), attempt.recoveryReceiptRevision);
notice(attempt.owner, result.name(), "Equipment recovery refused: " + result);
} else sync(attempt);
} else {
if ((!modeRestored || !vitalsRestored) && equipmentEvents != null) {
equipmentEvents.resolved(attempt.restoreAction, "pending", "receipt_incomplete");
equipmentEvents.resolved(attempt.recoveryAction, "pending", "receipt_incomplete");
}
sync(attempt);
}
return result;
} finally {
if (reserved) transitioning.remove(attempt.owner, attempt);
@@ -1066,6 +1162,18 @@ public final class EquipmentSessions implements AutoCloseable {
+ ". Items and receipt retained. RESTORED receipts require manual reconciliation too.");
return EquipmentStorage.RestoreResult.INVALID_BOX;
}
if (equipmentEvents != null && attempt.restoreEvent == null) try {
equipment.snapshot(attempt.player.getInventory(), attempt.owner, attempt.session,
attempt.player.getItemOnCursor()).ifPresent(box -> {
var head = attempt.player.getInventory().getItem(39);
boolean displaced = box.displacedHead() && !controls.owned(head, attempt.owner, attempt.session)
&& (gear == null || !gear.owned(head, attempt.owner, attempt.session));
attempt.restoreEvent = equipmentEvents.capture(attempt.player, attempt.session,
attempt.character, attempt.record == null ? null : attempt.record.name(),
new EquipmentStorage.Snapshot(box.boxSource(), box.boxSlot(), box.hotbarSlots(),
box.armorSlots(), displaced));
});
} catch (RuntimeException observationFailure) { diagnostic(attempt, observationFailure); }
mark(attempt, EquipmentSessionMarker.Phase.RESTORING);
var result = equipment.restore(attempt.player.getInventory(), attempt.owner, attempt.session,
item -> controls.owned(item, attempt.owner, attempt.session)||(gear!=null&&gear.owned(item,attempt.owner,attempt.session)),
@@ -1091,19 +1199,78 @@ public final class EquipmentSessions implements AutoCloseable {
persist(() -> characters.saveHealth(attempt.character, remaining, limit), (saved, failure) -> {
if (failure != null || saved.isEmpty()) {
diagnostic(attempt, failure != null ? failure : new IllegalStateException("Character unavailable for health save"));
attempt.healthSaveFailed = true;
if (attempt.nativeRestored) recoveryIncomplete(attempt, "health_unverified");
return;
}
attempt.healthAcknowledged = true;
if (attempt.nativeRestored) persistExit(attempt, true);
});
} catch (RuntimeException failure) {
attempt.healthSaveFailed = true;
diagnostic(attempt, failure);
}
}
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,
attempt.recoveryPriorPhase, "restored", "native_restored_receipt_pending",
"ambiguous", reason, attempt.recoveryReceiptRevision);
}
sessionReconciled(attempt, "restored", "ambiguous", "native_restored_receipt_pending",
reason, attempt.recoveryReceiptRevision);
}
private void captureRecoveryLocations(Attempt attempt) {
if (sessionRecoveryEvents == null || attempt.sessionRecoveryAction == null) return;
attempt.recoveryOriginalLocation = sessionRecoveryEvents.captureLocation(attempt.entryLocation);
attempt.recoveryCurrentLocation = sessionRecoveryEvents.captureLocation(attempt.player.getLocation());
}
private void sessionReconciled(Attempt attempt, String phase, String outcome, String quality,
String reason, java.math.BigInteger revision) {
if (sessionRecoveryEvents == null || attempt.sessionRecoveryAction == null || attempt.sessionRecoveryResolved) return;
attempt.sessionRecoveryResolved = true;
try {
sessionRecoveryEvents.reconciled(attempt.sessionRecoveryAction, attempt.session, attempt.character,
attempt.recoveryPriorPhase, phase, outcome, quality, reason, attempt.recoveryReceiptRevision,
revision, attempt.recoveryAction == null ? null : attempt.recoveryAction.id(),
attempt.recoveryOriginalLocation, attempt.recoveryCurrentLocation);
} 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;
}
@@ -1125,19 +1292,54 @@ 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",
attempt.nativeRestored ? "native_restored_receipt_pending" : "receipt_unavailable",
"receipt_save_failed", attempt.recoveryReceiptRevision);
diagnostic(attempt, failure);
if (attempt.defeated) conversionUncertain(attempt,
"Equipment restoration persistence failed before Tail conversion");
} else if (complete) {
attempt.gearAcknowledged = true;
if (!attempt.restoreEventWritten && equipmentEvents != null && attempt.restoreEvent != null) {
attempt.restoreEventWritten = true;
equipmentEvents.restored(attempt.restoreEvent, state.revision());
}
if (equipmentEvents != null) {
equipmentEvents.resolved(attempt.restoreAction, "accepted", "receipt_completed");
if (attempt.recoveryAction != null && !attempt.recoveryResolved) {
attempt.recoveryResolved = true;
equipmentEvents.resolved(attempt.recoveryAction, "accepted", "receipt_completed");
equipmentEvents.recovery(attempt.recoveryAction,
attempt.session, attempt.character, attempt.recoveryPriorPhase, "restored",
attempt.restoreEvent == null ? "receipt_and_marker_only" : "verified_box_and_marker",
"restored", "receipt_completed", state.revision());
}
}
sessionReconciled(attempt, "restored", "restored",
attempt.restoreEvent == null ? "receipt_and_marker_only" : "verified_box_and_marker",
"receipt_completed", state.revision());
if (!attempt.switchEventWritten && switchEvents != null) {
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");
}
});
@@ -1243,16 +1445,21 @@ public final class EquipmentSessions implements AutoCloseable {
}
/** Explicit proof-based reconciliation; never infer restoration from an absent box. */
public synchronized void recover(Player player) { recover(player, false); }
public synchronized void recover(Player player) { recover(player, false, null); }
public synchronized void recover(Player player, games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action action) {
recover(player, false, action);
}
/** Join reconciliation: recover a pending receipt, otherwise request normal entry. */
public synchronized void arrive(Player player) {
var directory=downedDirectory;
if(directory!=null){var death=directory.all().stream().filter(value->value.player().equals(player.getUniqueId())&&value.phase()==DownedEpisode.Phase.RESOLVED&&value.outcome()==DownedEpisode.Outcome.FINAL_DEATH&&!value.published()).findFirst().orElse(null);if(death!=null){reconcileOfflineFinalDeath(player,death);return;}}
recover(player,true);
recover(player,true,null);
}
private void recover(Player player, boolean arrival) {
private void recover(Player player, boolean arrival,
games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action action) {
UUID owner = player.getUniqueId();
invalidateCursor(owner);
if (closed || !player.isOnline()) return;
@@ -1271,6 +1478,7 @@ public final class EquipmentSessions implements AutoCloseable {
cancelEntry(owner);
var attempt = new Attempt(player, owner, null);
attempt.recoveryOwner = previous;
attempt.sessionRecoveryAction = action;
// A recovered receipt has no process-local never-packed proof.
attempt.effectsStarted = true;
attempt.arrival = arrival;
@@ -1287,7 +1495,16 @@ public final class EquipmentSessions implements AutoCloseable {
notice(attempt.owner, "STALE_RECOVERY", "Session ownership changed after the recovery read; no old receipt was replayed.");
return;
}
if (failure != null) { diagnostic(attempt, failure); return; }
if (failure != null) {
if (sessionRecoveryEvents != null) {
var action = attempt.sessionRecoveryAction == null
? sessionRecoveryEvents.system(attempt.player) : attempt.sessionRecoveryAction;
sessionRecoveryEvents.reconciled(action, null, null, "unavailable", "unavailable",
"failed", "receipt_unreadable", "receipt_read_failed", null, null, null, null,
sessionRecoveryEvents.captureLocation(attempt.player.getLocation()));
}
diagnostic(attempt, failure); return;
}
var receipt = state.map(EquipmentSessionStore.State::pending).orElse(null);
if (receipt == null) {
if (attempt.arrival) {
@@ -1298,10 +1515,25 @@ public final class EquipmentSessions implements AutoCloseable {
}
return; // Ordinary no-sheet/no-receipt arrivals are not rejected entry attempts.
}
if (equipmentEvents != null) equipmentEvents.resolved(
equipmentEvents.action(attempt.player, null, null, "equipment_recovery"), "refused", "no_receipt");
if (sessionRecoveryEvents != null) {
var action = attempt.sessionRecoveryAction == null
? sessionRecoveryEvents.system(attempt.player) : attempt.sessionRecoveryAction;
sessionRecoveryEvents.reconciled(action, null, null, "absent", "absent", "refused",
"receipt_missing", "no_receipt", null, null, null, null,
sessionRecoveryEvents.captureLocation(attempt.player.getLocation()));
}
notice(attempt.owner, "NO_RECEIPT", "No saved equipment receipt is available; items were left unchanged."); return;
}
attempt.character = receipt.characterId();
attempt.session = receipt.sessionId();
attempt.recoveryReceiptRevision = state.orElseThrow().revision();
if (sessionRecoveryEvents != null && attempt.sessionRecoveryAction == null)
attempt.sessionRecoveryAction = sessionRecoveryEvents.system(attempt.player);
if (equipmentEvents != null) attempt.recoveryAction = equipmentEvents.action(
attempt.player, attempt.session, attempt.character,
attempt.arrival ? "equipment_arrival_recovery" : "equipment_recovery");
attempt.previousGameMode = receipt.previousGameMode();
attempt.ordinaryVitals = receipt.ordinaryVitals();
attempt.vitalsStarted = attempt.ordinaryVitals != null;
@@ -1309,6 +1541,7 @@ public final class EquipmentSessions implements AutoCloseable {
attempt.modeStarted = attempt.previousGameMode != null;
var previous = owned.get(attempt.owner);
if (previous != null && !attempt.session.equals(previous.session)) {
if (equipmentEvents != null) equipmentEvents.resolved(attempt.recoveryAction, "refused", "session_mismatch");
notice(attempt.owner, "SESSION_MISMATCH", "Saved and process-local sessions disagree; items were left unchanged."); return;
}
if (previous != null && (previous.defeated || previous.vitalsStarted)) {
@@ -1316,6 +1549,11 @@ public final class EquipmentSessions implements AutoCloseable {
// proof. Keep the original death authority and its pending worker completion;
// do not replace it with a recovery Attempt that could release its claim early.
previous.deliveryInvalidated = true;
previous.recoveryAction = attempt.recoveryAction;
previous.sessionRecoveryAction = attempt.sessionRecoveryAction;
previous.recoveryReceiptRevision = attempt.recoveryReceiptRevision;
previous.recoveryPriorPhase = EquipmentSessionMarker.read(attempt.player)
.map(token -> token.phase().name().toLowerCase(java.util.Locale.ROOT)).orElse("unavailable");
restoreExit(previous);
return;
}
@@ -1325,17 +1563,47 @@ public final class EquipmentSessions implements AutoCloseable {
}
owned.put(attempt.owner, attempt);
transitioning.put(attempt.owner, attempt);
attempt.recoveryPriorPhase = EquipmentSessionMarker.read(attempt.player)
.map(token -> token.phase().name().toLowerCase(java.util.Locale.ROOT)).orElse("unavailable");
try {
saveRemainingHealth(attempt);
boolean modeRestored = restoreGameMode(attempt);
var result = restoreMarked(attempt);
boolean vitalsRestored = restoreVitals(attempt);
captureRecoveryLocations(attempt);
if (result == EquipmentStorage.RestoreResult.RESTORED) {
if (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged) attempt.gearRestoredForModeRecovery = true;
if (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged) {
attempt.gearRestoredForModeRecovery = true;
if (equipmentEvents != null) {
var reason = !attempt.healthAcknowledged ? "health_unverified" : "native_restore_incomplete";
equipmentEvents.resolved(attempt.recoveryAction, "pending", reason);
if (attempt.healthSaveFailed || !modeRestored || !vitalsRestored)
recoveryIncomplete(attempt, reason);
}
}
persistExit(attempt, modeRestored && vitalsRestored);
sync(attempt);
} else notice(attempt.owner, result.name(), "Equipment recovery refused: " + result + "; receipt and items retained.");
} else {
if (equipmentEvents != null) {
equipmentEvents.resolved(attempt.recoveryAction, "retained", result.name().toLowerCase(java.util.Locale.ROOT));
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
attempt.recoveryPriorPhase, "pending", "receipt_and_marker_only", "retained",
result.name().toLowerCase(java.util.Locale.ROOT), state.orElseThrow().revision());
}
sessionReconciled(attempt, "pending", "retained", "receipt_and_marker_only",
result.name().toLowerCase(java.util.Locale.ROOT), attempt.recoveryReceiptRevision);
notice(attempt.owner, result.name(), "Equipment recovery refused: " + result + "; receipt and items retained.");
}
} catch (RuntimeException error) {
if (equipmentEvents != null) {
equipmentEvents.resolved(attempt.recoveryAction, "uncertain", "native_exception");
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
attempt.recoveryPriorPhase, "restoring", "native_uncertain", "ambiguous",
"native_exception", attempt.recoveryReceiptRevision);
}
captureRecoveryLocations(attempt);
sessionReconciled(attempt, "restoring", "ambiguous", "native_uncertain",
"native_exception", attempt.recoveryReceiptRevision);
fail(attempt, error);
notice(attempt.owner, "NATIVE_UNCERTAIN", "Recovery mutation failed; native phase, items and receipt retained for inspection.");
} finally {
@@ -1357,7 +1625,7 @@ public final class EquipmentSessions implements AutoCloseable {
if (attempt.defeated && !attempt.bookDelivered) conversionUncertain(attempt, "Shutdown before confirmed Tail conversion");
}
for (var owner : java.util.List.copyOf(pending.keySet())) cancelEntry(owner);
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player);
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player, "shutdown");
characterRecoveries.clear();
recoveryRecipients.clear();
claims.close();
@@ -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,431 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered bound slot-2 input -> native damage -> local JSONL, not a direct bridge test. */
class BasicAttackEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void eventSnapshotReadsExportedSegmentsWithoutEnteringTransientPendingTree() throws Exception {
var events = data.resolve("events");
var day = events.resolve(java.time.LocalDate.now(java.time.ZoneOffset.UTC).toString());
var pending = events.resolve("pending");
Files.createDirectories(day); Files.createDirectories(pending);
Files.writeString(day.resolve("events-exported-0.jsonl"),
"{\"type\":\"games.dmg.minecraft.fantasystick.combat.damage\"}\n");
Files.writeString(pending.resolve("transient.jsonl"),
"{\"type\":\"games.dmg.minecraft.fantasystick.action.observed\"}\n");
var exported = records();
assertEquals(1, exported.size(), "Only durable exported date-segments are authoritative test evidence");
assertTrue(is(exported.getFirst(), "combat.damage"));
}
@Test void acceptedNativeBasicHitHasOneCorrelatedDamageRecordAndRealFinalAmount() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = actor(fixture); var target = actor(fixture);
fixture.activate(source); fixture.activate(target);
source.core.inventory.getInventory().setSelectedSlot(1);
doAnswer(call -> {
var damageSource = mock(org.bukkit.damage.DamageSource.class);
when(damageSource.getCausingEntity()).thenReturn(source.player);
when(damageSource.getDirectEntity()).thenReturn(source.player);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player, target.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, damageSource,
(Double) call.getArgument(0));
event.setDamage(0.75);
fixture.dispatcher.accept(event);
assertFalse(event.isCancelled());
return null;
}).when(target.player).damage(anyDouble(), same(source.player));
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(source.player, target.player, true);
fixture.dispatcher.accept(attack);
assertTrue(attack.isCancelled(), "Bound stick may not continue to vanilla damage");
for (int i = 0; i < 250 && records().stream().noneMatch(value -> is(value, "combat.damage")); i++)
Thread.sleep(20);
fixture.plugin.setEnabled(false);
var entries = records();
var observed = actions(entries, "action.observed");
var resolved = actions(entries, "action.resolved");
var damage = entries.stream().filter(value -> is(value, "combat.damage")).toList();
assertEquals(1, observed.size()); assertEquals(1, resolved.size()); assertEquals(1, damage.size());
var start = observed.getFirst().getAsJsonObject("data");
var result = resolved.getFirst().getAsJsonObject("data");
var hit = damage.getFirst().getAsJsonObject("data");
assertEquals("basic_attack", start.get("action_kind").getAsString());
assertEquals(start.get("action_id").getAsString(), result.get("action_id").getAsString());
assertEquals(start.get("action_id").getAsString(), hit.get("action_id").getAsString());
assertEquals("accepted", result.get("outcome").getAsString());
assertEquals("1.0", hit.get("requested_amount").getAsString());
assertEquals("0.75", hit.get("final_amount").getAsString());
assertEquals(source.core.id.toString(), hit.get("source_player_id").getAsString());
assertEquals(target.core.id.toString(), hit.get("target_player_id").getAsString());
assertTrue(start.has("source_location")); assertTrue(hit.has("target_location"));
assertTrue(start.has("ready_deadline"));
}
}
@Test void partyVetoZeroDamageAndExactCadenceDoNotMintExtraSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = actor(fixture); var target = actor(fixture);
strength(source, 12);
fixture.activate(source); fixture.activate(target);
source.core.inventory.getInventory().setSelectedSlot(1);
var basic = (games.dmg.fantasystick.combat.BasicAttack)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "basicAttack");
var clock = new BasicAttackNativeTest.MutableClock(java.time.Instant.now());
var field = games.dmg.fantasystick.combat.BasicAttack.class.getDeclaredField("clock");
field.setAccessible(true); field.set(basic, clock);
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(source.core.id).orElseThrow();
var invitation = parties.invite(source.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
var requests = new ArrayList<Double>();
var zero = new java.util.concurrent.atomic.AtomicBoolean(false);
doAnswer(call -> {
double requested = call.getArgument(0);
requests.add(requested);
var nativeSource = mock(org.bukkit.damage.DamageSource.class);
when(nativeSource.getCausingEntity()).thenReturn(source.player);
when(nativeSource.getDirectEntity()).thenReturn(source.player);
var nativeHit = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player, target.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, nativeSource, requested);
if (zero.get()) nativeHit.setDamage(0);
fixture.dispatcher.accept(nativeHit);
return null;
}).when(target.player).damage(anyDouble(), same(source.player));
attack(fixture, source, target); // Party guard rejects this native attempt.
assertTrue(parties.leave(target.core.id, group.id()));
zero.set(true); attack(fixture, source, target); // Zero final amount, no deadline.
zero.set(false); attack(fixture, source, target); // STR 12: base 1 + modifier 1.
clock.advance(java.time.Duration.ofMillis(999)); attack(fixture, source, target);
assertEquals(3, requests.size(), "Early input cannot trigger a fourth native hit");
clock.advance(java.time.Duration.ofMillis(1)); attack(fixture, source, target);
assertEquals(List.of(2.0, 2.0, 2.0, 2.0), requests);
fixture.plugin.setEnabled(false);
var entries = records();
var observed = actions(entries, "action.observed");
var resolved = actions(entries, "action.resolved");
var damage = entries.stream().filter(value -> is(value, "combat.damage")).toList();
assertEquals(5, observed.size()); assertEquals(5, resolved.size()); assertEquals(2, damage.size());
assertEquals(List.of("cancelled", "refused", "accepted", "refused", "accepted"),
resolved.stream().map(value -> value.getAsJsonObject("data").get("outcome").getAsString()).toList());
assertEquals(List.of("native_cancelled", "zero_or_invalid_damage", "native_positive_hit",
"cadence_not_ready", "native_positive_hit"), resolved.stream()
.map(value -> value.getAsJsonObject("data").get("reason").getAsString()).toList());
assertTrue(damage.stream().allMatch(value -> value.getAsJsonObject("data").get("strength").getAsString().equals("12")
&& value.getAsJsonObject("data").get("requested_amount").getAsString().equals("2.0")));
assertEquals(2, damage.stream().map(value -> value.getAsJsonObject("data").get("action_id").getAsString())
.distinct().count());
}
}
@Test void optionalStickKnockbackDoesNotCreateAnotherDamageOutcome() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = actor(fixture);
strength(source, java.math.BigInteger.valueOf(12), List.of("stick_knockback", "", ""));
fixture.activate(source);
source.core.inventory.getInventory().setSelectedSlot(1);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doAnswer(call -> {
var nativeSource = mock(org.bukkit.damage.DamageSource.class);
when(nativeSource.getDirectEntity()).thenReturn(source.player);
when(nativeSource.getCausingEntity()).thenReturn(source.player);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player,
target, org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
nativeSource, (Double) call.getArgument(0)));
return null;
}).when(target).damage(anyDouble(), same(source.player));
fixture.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(source.player, target, true));
verify(target).knockback(eq(1.0), anyDouble(), anyDouble());
fixture.plugin.setEnabled(false);
var entries = records();
assertEquals(1, entries.stream().filter(value -> is(value, "combat.damage")).count());
assertEquals(1, actions(entries, "action.resolved").size());
}
}
@Test void staleInputAfterExitAndRestartCannotReplayPreviousAcceptedHit() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = actor(fixture); var target = actor(fixture);
fixture.activate(source); fixture.activate(target);
source.core.inventory.getInventory().setSelectedSlot(1);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
var old = sessions.active(source.core.id).orElseThrow();
var requests = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> {
requests.incrementAndGet();
var nativeSource = mock(org.bukkit.damage.DamageSource.class);
when(nativeSource.getDirectEntity()).thenReturn(source.player);
when(nativeSource.getCausingEntity()).thenReturn(source.player);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player,
target.player, org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
nativeSource, (Double) call.getArgument(0)));
return null;
}).when(target.player).damage(anyDouble(), same(source.player));
attack(fixture, source, target);
var sheet = source.core.inventory.getItemInOffHand().clone();
var targetSheet = target.core.inventory.getItemInOffHand().clone();
source.core.inventory.setItemInOffHand(null); fixture.changed(source);
target.core.inventory.setItemInOffHand(null); fixture.changed(target);
awaitCleanExit(fixture, sessions, source);
awaitCleanExit(fixture, sessions, target);
fixture.plugin.setEnabled(false); fixture.awaitWorkers();
fixture.plugin.setEnabled(true);
var resumed = (games.dmg.fantasystick.combat.BasicAttack)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "basicAttack");
var stale = new games.dmg.fantasystick.session.ControlInput(source.player, old, 1, "",
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target.player, 0, 1,
() -> false);
resumed.accept(stale); resumed.accept(stale);
assertEquals(1, requests.get(), "Stale/duplicate notifications cannot replay native damage");
var recoveredSessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
awaitOrdinaryArrival(fixture, recoveredSessions, source);
awaitOrdinaryArrival(fixture, recoveredSessions, target);
enterAfterRestoredExit(fixture, recoveredSessions, source, sheet);
enterAfterRestoredExit(fixture, recoveredSessions, target, targetSheet);
assertTrue(recoveredSessions.active(target.core.id).isPresent(),
"Fresh entry after proved ordinary arrival must own a current target session");
source.core.inventory.getInventory().setSelectedSlot(1);
attack(fixture, source, target);
assertEquals(2, requests.get(), "Fresh session gets a fresh action after restart");
fixture.plugin.setEnabled(false);
var entries = records();
assertEquals(2, entries.stream().filter(value -> is(value, "combat.damage")).count());
assertEquals(2, actions(entries, "action.resolved").stream()
.filter(value -> value.getAsJsonObject("data").get("reason").getAsString().equals("stale_input")).count());
assertEquals(4, actions(entries, "action.observed").size());
}
}
@Test void mixedModeAndPriorNativeVetoNeverConsumeCadenceOrPublishDamage() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = actor(fixture); var ordinary = actor(fixture);
fixture.activate(source);
source.core.inventory.getInventory().setSelectedSlot(1);
var priorVeto = new java.util.concurrent.atomic.AtomicBoolean(false);
var nativeHits = new ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> {
var nativeSource = mock(org.bukkit.damage.DamageSource.class);
when(nativeSource.getCausingEntity()).thenReturn(source.player);
when(nativeSource.getDirectEntity()).thenReturn(source.player);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player, ordinary.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, nativeSource,
(Double) call.getArgument(0));
if (priorVeto.get()) event.setCancelled(true);
nativeHits.add(event);
fixture.dispatcher.accept(event);
return null;
}).when(ordinary.player).damage(anyDouble(), same(source.player));
attack(fixture, source, ordinary); // Fantasy against ordinary: native mixed-mode veto.
assertTrue(nativeHits.getFirst().isCancelled());
fixture.activate(ordinary);
priorVeto.set(true); attack(fixture, source, ordinary);
assertTrue(nativeHits.getLast().isCancelled(), "Pre-existing native veto is never uncancelled");
priorVeto.set(false); attack(fixture, source, ordinary);
assertFalse(nativeHits.getLast().isCancelled(), "Neither refusal spent the first accepted cadence");
assertEquals(3, nativeHits.size());
fixture.plugin.setEnabled(false);
var results = actions(records(), "action.resolved");
assertEquals(List.of("cancelled", "cancelled", "accepted"), results.stream()
.map(value -> value.getAsJsonObject("data").get("outcome").getAsString()).toList());
assertEquals("prior_cancelled", results.get(1).getAsJsonObject("data").get("reason").getAsString());
assertTrue(results.get(1).getAsJsonObject("data").get("native_prior_cancelled").getAsBoolean());
assertFalse(results.getFirst().getAsJsonObject("data").get("native_prior_cancelled").getAsBoolean());
assertEquals(1, records().stream().filter(value -> is(value, "combat.damage")).count());
assertTrue(results.stream().allMatch(value -> value.getAsJsonObject("data").get("source_location") != null
&& value.getAsJsonObject("data").get("target_location") != null));
}
}
@Test void strengthBoundariesReachOneOwnedNativeDamageRecordEach() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var target = actor(fixture); fixture.activate(target);
int[] strengths = {0, 9, 10, 11, 12, 13, 14, 15};
double[] expected = {1, 1, 1, 1, 2, 2, 3, 3};
for (int i = 0; i < strengths.length + 1; i++) {
var source = actor(fixture);
strength(source, i < strengths.length ? java.math.BigInteger.valueOf(strengths[i])
: java.math.BigInteger.TEN.pow(1000));
fixture.activate(source);
source.core.inventory.getInventory().setSelectedSlot(1);
double requested = i < strengths.length ? expected[i] : Double.MAX_VALUE;
doAnswer(call -> {
assertEquals(requested, (Double) call.getArgument(0));
var nativeSource = mock(org.bukkit.damage.DamageSource.class);
when(nativeSource.getCausingEntity()).thenReturn(source.player);
when(nativeSource.getDirectEntity()).thenReturn(source.player);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player,
target.player, org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
nativeSource, requested));
return null;
}).when(target.player).damage(eq(requested), same(source.player));
attack(fixture, source, target);
source.core.inventory.setItemInOffHand(null); fixture.changed(source);
fixture.awaitCharacterAndReceipt(source);
}
fixture.plugin.setEnabled(false);
var entries = records();
var hits = entries.stream().filter(value -> is(value, "combat.damage")).map(value ->
value.getAsJsonObject("data")).toList();
assertEquals(9, hits.size());
assertEquals(List.of("1.0", "1.0", "1.0", "1.0", "2.0", "2.0", "3.0", "3.0",
Double.toString(Double.MAX_VALUE)), hits.stream()
.map(value -> value.get("requested_amount").getAsString()).toList());
assertEquals(9, hits.stream().map(value -> value.get("action_id").getAsString()).distinct().count());
assertTrue(hits.stream().allMatch(value -> value.get("final_amount").getAsString()
.equals(value.get("requested_amount").getAsString())));
}
}
private static void enterAfterRestoredExit(FantasyStickLifecycleNativeTest.Fixture fixture,
games.dmg.fantasystick.session.EquipmentSessions sessions,
FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.inventory.ItemStack sheet) throws Exception {
assertTrue(actor.core.inventory.getItemInOffHand() == null
|| actor.core.inventory.getItemInOffHand().isEmpty(),
"Observe the ordinary empty offhand before a fresh sheet move");
fixture.changed(actor); // First RESTORED observation is not a new entry request.
actor.core.inventory.setItemInOffHand(sheet);
fixture.changed(actor);
for (int i = 0; i < 80 && (sessions.active(actor.core.id).isEmpty()
|| actor.core.marker() == null || !actor.core.marker().endsWith("|PACKED")); i++) {
var task = fixture.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(task, "Fresh entry has no callback after proved restored exit; feedback=" + actor.messages);
task.run();
}
assertTrue(sessions.active(actor.core.id).isPresent(), "Fresh entry must own a current session");
assertTrue(actor.core.marker().endsWith("|PACKED"), "Native equipment must be packed for the new session");
}
private static void awaitCleanExit(FantasyStickLifecycleNativeTest.Fixture fixture,
games.dmg.fantasystick.session.EquipmentSessions sessions,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
var transitioning = (java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "transitioning");
for (int i = 0; i < 80; i++) {
if (sessions.active(actor.core.id).isEmpty() && !transitioning.containsKey(actor.core.id)
&& restoredExitProof(fixture, actor)) return;
var task = fixture.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(task, "No exit callback before acknowledged receipt and native RESTORED marker");
task.run();
}
fail("Session exit did not clear owned native marker and acknowledged receipt before restart");
}
private static void awaitOrdinaryArrival(FantasyStickLifecycleNativeTest.Fixture fixture,
games.dmg.fantasystick.session.EquipmentSessions sessions,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
var pending = (java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "pending");
var transitioning = (java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "transitioning");
for (int i = 0; i < 80 && (pending.containsKey(actor.core.id)
|| transitioning.containsKey(actor.core.id)); i++) fixture.tick();
assertFalse(pending.containsKey(actor.core.id), "Startup arrival must settle before a new entry request");
assertFalse(transitioning.containsKey(actor.core.id), "No unresolved native transition may be replaced");
assertTrue(sessions.active(actor.core.id).isEmpty(), "An ordinary holder cannot have a recovered active session");
assertTrue(restoredExitProof(fixture, actor),
"A completed receipt and matching RESTORED native marker are required before re-entry");
}
private static boolean restoredExitProof(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
return fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS)
.filter(state -> state.pending() == null && state.lastExit() != null)
.map(state -> ("1|" + state.lastExit().sessionId() + "|" + state.lastExit().characterId()
+ "|RESTORED").equals(actor.core.marker()))
.orElse(false);
}
private static void attack(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor source,
FantasyStickLifecycleNativeTest.Actor target) {
var event = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(source.player, target.player, true);
fixture.dispatcher.accept(event);
assertTrue(event.isCancelled());
}
private void strength(FantasyStickLifecycleNativeTest.Actor actor, int value) throws Exception {
strength(actor, java.math.BigInteger.valueOf(value));
}
private void strength(FantasyStickLifecycleNativeTest.Actor actor, java.math.BigInteger value) throws Exception {
strength(actor, value, null);
}
private void strength(FantasyStickLifecycleNativeTest.Actor actor, java.math.BigInteger value,
List<String> passives) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.STR, value);
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(), old.unspentPoints(),
old.health(), old.learned(), old.loadout(), passives == null ? old.passives() : passives,
old.cooldowns(), old.restrictedKills(),
false, null, null, old.coins(), old.coinsInitialized(), old.rewardReceipts(), old.pvpCoinEscrow(), old.gear()),
old.revision());
}
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture fixture) throws Exception {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
return actor;
}
private static boolean is(JsonObject event, String suffix) {
return event.get("type").getAsString().endsWith(suffix);
}
private static List<JsonObject> actions(List<JsonObject> entries, String suffix) {
return entries.stream().filter(value -> is(value, suffix)
&& value.getAsJsonObject("data").has("action_kind")
&& value.getAsJsonObject("data").get("action_kind").getAsString().equals("basic_attack")).toList();
}
private List<JsonObject> records() throws Exception {
var result = new ArrayList<JsonObject>();
var directory = data.resolve("events");
if (!Files.exists(directory)) return result;
// Only date-segment directories contain exported records. Walking the entire tree races the
// writer's create/move/delete of transient files under events/pending during the live poll.
try (var days = Files.list(directory)) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path);
int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList())
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
}
}
}
}
return result;
}
}
@@ -232,6 +232,52 @@ class CharacterBookNativeTest {
}
}
@Test void previousVolumeReturnsToOverviewWithoutInventingAnotherBookOpen() throws Exception {
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); f.activate(actor);
var old = actor.core.record;
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
stats.putAll(old.stats()); stats.put(games.dmg.fantasystick.progression.Stat.STR,
new java.math.BigInteger("1234567890".repeat(4000)));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(),
java.math.BigInteger.ZERO, old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
click(f, actor); drainReads(f); runQueued(f);
var next = PartyBookNativeTest.button(actor, "[Next pages]");
PartyBookNativeTest.click(f, actor, next); drainReads(f); runQueued(f);
assertTrue(actor.books.size() >= 2);
var previous = PartyBookNativeTest.button(actor, "[Previous pages]");
PartyBookNativeTest.click(f, actor, previous); drainReads(f); runQueued(f);
assertEquals(3, actor.books.size());
PartyBookNativeTest.click(f, actor, previous);
assertEquals(3, actor.books.size(), "Replayed nonce cannot reopen a volume");
}
var records = new java.util.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()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
var opened = records.stream().filter(e -> e.get("type").getAsString().endsWith("character_book.opened"))
.map(e -> e.get("id").getAsString()).distinct().toList();
assertEquals(1, opened.size());
var navigations = records.stream().filter(e -> e.get("type").getAsString().endsWith("character_book.navigated"))
.collect(java.util.stream.Collectors.toMap(e -> e.get("id").getAsString(), e -> e,
(first, duplicate) -> first, java.util.LinkedHashMap::new));
assertEquals(2, navigations.size());
var previous = navigations.values().stream().map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("volume_direction").getAsString().equals("previous")).findFirst().orElseThrow();
assertEquals("30", previous.get("source_volume_start").getAsString());
assertEquals("0", previous.get("destination_volume_start").getAsString());
assertEquals("overview", previous.get("destination_section").getAsString());
assertTrue(records.stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("reason")
&& e.getAsJsonObject("data").get("reason").getAsString().equals("stale_or_forged_payload")));
}
@Test void largeRecordsRemainCompleteThroughBoundedSingleUseNavigation() throws Exception {
int expectedNavigations;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
@@ -279,6 +325,16 @@ class CharacterBookNativeTest {
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml")));
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(expectedNavigations,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.navigated")).count());assertEquals(expectedNavigations,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.navigated")&&event.getAsJsonObject("data").get("native_open").getAsString().equals("acknowledged")&&event.getAsJsonObject("data").get("offset").getAsInt()>0).count());
var navigation=events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.navigated"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("0",navigation.get("source_volume_start").getAsString());
assertEquals("30",navigation.get("destination_volume_start").getAsString());
assertEquals("next",navigation.get("volume_direction").getAsString());
assertTrue(navigation.get("source_navigation_page").getAsInt()>0);
assertEquals("1",navigation.get("destination_page").getAsString());
assertEquals("volume_start",navigation.get("destination_section").getAsString());
assertFalse(navigation.has("ability_id"));assertFalse(navigation.has("passive_id"));
assertTrue(navigation.has("location"));
}
@Test void acknowledgedRenameTransferAndReloadInspectTheCurrentRecordWithoutCarryingTheFormerHoldersView() throws Exception {
@@ -0,0 +1,391 @@
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 games.dmg.fantasystick.persistence.YamlCharacterRepository;
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.bukkit.Location;
import org.bukkit.craftbukkit.CraftWorld;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.CraftItemEvent;
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 CharacterIdentityEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredManualCraftOnlyPublishesAnAcknowledgedDeliveredCharacter() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
var world = mock(CraftWorld.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.getName()).thenReturn("world");
actor.eyeLocation = new Location(world, 12, 70, 31);
var preview = fixture.recipes.get(CharacterSheets.RECIPE);
var event = new CraftItemEvent(preview, actor.fixture.view, InventoryType.SlotType.RESULT, 0,
ClickType.LEFT, InventoryAction.PICKUP_ALL);
fixture.dispatcher.accept(event);
assertTrue(event.isCancelled());
var complete = fixture.tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(complete);
complete.run();
var id = new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow();
var saved = new YamlCharacterRepository(data).load(id).orElseThrow();
complete.run(); // A repeated completion cannot create or deliver a second logical success.
for (int i = 0; i < 100 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("character.created")); i++) Thread.sleep(10);
fixture.plugin.setEnabled(false);
var created = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.created")).filter(entry -> entry.getAsJsonObject("data")
.get("character_id").getAsString().equals(id.toString())).toList();
assertEquals(1, created.stream().map(entry -> entry.get("id").getAsString()).distinct().count());
var values = created.getFirst().getAsJsonObject("data");
assertEquals(saved.revision().toString(), values.get("revision").getAsString());
assertEquals("fantasystick:character_sheet", values.get("recipe_id").getAsString());
assertEquals("cursor", values.get("delivery_route").getAsString());
assertEquals(actor.id.toString(), values.get("holder_player_id").getAsString());
assertEquals("no_active_session", values.get("holder_session_status").getAsString());
assertFalse(values.has("holder_session_id"));
assertEquals(world.getUID().toString(), values.getAsJsonObject("location").get("world_id").getAsString());
}
}
@Test void priorCancellationAndStaleCraftDoNotInventCreation() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
var recipe = fixture.recipes.get(CharacterSheets.RECIPE);
var cancelled = new CraftItemEvent(recipe, actor.fixture.view, InventoryType.SlotType.RESULT, 0,
ClickType.LEFT, InventoryAction.PICKUP_ALL);
cancelled.setCancelled(true);
fixture.dispatcher.accept(cancelled);
assertTrue(fixture.tasks.isEmpty());
var stale = new CraftItemEvent(recipe, actor.fixture.view, InventoryType.SlotType.RESULT, 0,
ClickType.LEFT, InventoryAction.PICKUP_ALL);
fixture.dispatcher.accept(stale);
assertTrue(stale.isCancelled());
var complete = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
actor.fixture.grid.getMatrix()[0].setAmount(7);
complete.run();
assertTrue(actor.fixture.view.getCursor().isEmpty());
fixture.plugin.setEnabled(false);
var entries = records();
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString().endsWith("character.created")));
var resolutions = entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
.map(entry -> entry.getAsJsonObject("data"))
.filter(details -> details.has("action_kind")
&& details.get("action_kind").getAsString().equals("character_craft")).toList();
assertTrue(resolutions.stream().anyMatch(details -> details.get("outcome").getAsString().equals("cancelled")));
assertTrue(resolutions.stream().anyMatch(details -> details.get("outcome").getAsString().equals("stale")));
}
}
@Test void failedNativeCraftSaveNeverReportsCreatedOrDeliversASheet() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
var before = actor.fixture.grid.getMatrix()[0].getAmount();
var event = new CraftItemEvent(fixture.recipes.get(CharacterSheets.RECIPE), actor.fixture.view,
InventoryType.SlotType.RESULT, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL);
Files.createDirectories(data.resolve("characters"));
if (!Files.exists(data.resolve("characters/.records.lock"))) Files.createFile(data.resolve("characters/.records.lock"));
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),
java.nio.file.StandardOpenOption.WRITE); var lock = channel.lock()) {
assertTrue(lock.isValid());
fixture.dispatcher.accept(event);
var complete = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
complete.run();
}
assertTrue(event.isCancelled());
assertTrue(actor.fixture.view.getCursor().isEmpty());
assertEquals(before, actor.fixture.grid.getMatrix()[0].getAmount());
fixture.plugin.setEnabled(false);
var entries = records();
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString().endsWith("character.created")));
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
.map(entry -> entry.getAsJsonObject("data"))
.anyMatch(values -> values.has("action_kind") && values.get("action_kind").getAsString()
.equals("character_craft") && values.get("reason").getAsString().equals("save")));
}
}
@Test void cancelledNativeDropSpawnRemainsUnconfirmedWithoutClaimingACreatedSheet() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
var world = new PlayerSheetDropsNativeTest.WorldFixture();
actor.eyeLocation = new Location(world.world, 3, 72, 8);
world.onSpawn = spawn -> spawn.setCancelled(true);
var before = actor.fixture.grid.getMatrix()[0].getAmount();
var event = new CraftItemEvent(fixture.recipes.get(CharacterSheets.RECIPE), actor.fixture.view,
InventoryType.SlotType.RESULT, 0, ClickType.DROP, InventoryAction.DROP_ONE_SLOT);
fixture.dispatcher.accept(event);
var complete = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete); complete.run();
assertEquals(1, world.spawnCalls);
assertEquals(before, actor.fixture.grid.getMatrix()[0].getAmount());
assertTrue(actor.fixture.view.getCursor().isEmpty());
fixture.plugin.setEnabled(false);
var entries = records();
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("character.created")), "Unconfirmed spawn is not a delivered sheet");
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
.map(entry -> entry.getAsJsonObject("data"))
.anyMatch(value -> value.has("action_kind") && value.get("action_kind").getAsString()
.equals("character_craft") && value.get("reason").getAsString().equals("drop_unconfirmed")
&& value.get("outcome").getAsString().equals("uncertain")));
}
}
@Test void registeredNativeAnvilPublishesOnlyItsAcknowledgedUnicodeRename() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var original = new games.dmg.fantasystick.crafting.CharacterFactory()
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
var repository = new YamlCharacterRepository(data);
repository.create(original);
var anvil = new CharacterAnvilNamesNativeTest.AnvilFixture();
var world = mock(CraftWorld.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.getName()).thenReturn("world");
anvil.actor.eyeLocation = new Location(world, 4, 64, 9);
var sheet = new CharacterSheets().create(original);
var output = CharacterAnvilNamesNativeTest.named(sheet,
net.kyori.adventure.text.Component.text("雪 🐉 <literal>"));
anvil.grid.setItem(0, sheet);
anvil.grid.setItem(2, output);
var event = new org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent(anvil.actor.player, anvil.view, output);
fixture.dispatcher.accept(event);
fixture.dispatcher.accept(event);
var deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (repository.load(original.id()).orElseThrow().revision().equals(original.revision())
&& System.nanoTime() < deadline) Thread.sleep(5);
var saved = repository.load(original.id()).orElseThrow();
assertEquals("雪 🐉 <literal>", saved.name());
for (int i = 0; i < 100 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("character.renamed")); i++) Thread.sleep(10);
fixture.plugin.setEnabled(false);
var renamed = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.renamed")).toList();
assertEquals(1, renamed.stream().map(entry -> entry.get("id").getAsString()).distinct().count());
var values = renamed.getFirst().getAsJsonObject("data");
assertEquals(original.name(), values.get("before_name").getAsString());
assertEquals(saved.name(), values.get("after_name").getAsString());
assertEquals(original.revision().toString(), values.get("revision_before").getAsString());
assertEquals(saved.revision().toString(), values.get("revision_after").getAsString());
assertEquals(saved.sheetGeneration().toString(), values.get("generation").getAsString());
assertEquals("unverified", values.get("native_item_outcome").getAsString());
assertEquals(anvil.actor.id.toString(), values.get("holder_player_id").getAsString());
assertEquals("no_active_session", values.get("holder_session_status").getAsString());
assertEquals(world.getUID().toString(), values.getAsJsonObject("location").get("world_id").getAsString());
}
}
@Test void acknowledgedCraftAndRenameReplayOriginalEventsAfterWriterOutage() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
var event = new CraftItemEvent(fixture.recipes.get(CharacterSheets.RECIPE), actor.fixture.view,
InventoryType.SlotType.RESULT, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL);
fixture.dispatcher.accept(event);
var complete = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete); complete.run();
var id = new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow();
var saved = new YamlCharacterRepository(data).load(id).orElseThrow();
var anvil = new CharacterAnvilNamesNativeTest.AnvilFixture();
var sheet = new CharacterSheets().create(saved);
var result = CharacterAnvilNamesNativeTest.named(sheet,
net.kyori.adventure.text.Component.text("Archived 雪 🐉"));
anvil.grid.setItem(0, sheet); anvil.grid.setItem(2, result);
fixture.dispatcher.accept(new org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent(
anvil.actor.player, anvil.view, result));
var deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (new YamlCharacterRepository(data).load(id).orElseThrow().name().equals(saved.name())
&& System.nanoTime() < deadline) Thread.sleep(5);
assertEquals("Archived 雪 🐉", new YamlCharacterRepository(data).load(id).orElseThrow().name());
fixture.plugin.setEnabled(false);
fixture.awaitWorkers();
java.util.Set<String> pendingIds;
try (var files = Files.list(data.resolve("character-identity-event-intents"))) {
pendingIds = files.filter(path -> path.getFileName().toString().endsWith(".pending"))
.map(path -> path.getFileName().toString().replace(".pending", ""))
.collect(java.util.stream.Collectors.toSet());
}
assertEquals(2, pendingIds.size(), "Each acknowledged mutation has independent recoverable intent");
fixture.plugin.setEnabled(true);
for (int i = 0; i < 100 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("character.renamed")); i++) Thread.sleep(10);
fixture.plugin.setEnabled(false);
var domain = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.created") || entry.get("type").getAsString().endsWith("character.renamed")).toList();
assertEquals(pendingIds, domain.stream().map(entry -> entry.get("id").getAsString())
.collect(java.util.stream.Collectors.toSet()), "Recovery keeps the original accepted event IDs");
assertEquals(id.toString(), domain.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.created")).findFirst().orElseThrow().getAsJsonObject("data")
.get("character_id").getAsString());
}
}
@Test void distinctNativeHolderActivationObservesOnlyProvenCharacterTransfer() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var first = new FantasyStickLifecycleNativeTest.Actor(data);
var second = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(first.core.id, first.player);
fixture.players.put(second.core.id, second.player);
var world = mock(CraftWorld.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.getName()).thenReturn("other-world");
second.core.location.setWorld(world);
fixture.activate(first);
var firstSession = ((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions"))
.active(first.core.id).orElseThrow().session();
var sheet = first.core.inventory.getItemInOffHand().clone();
((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions")).exit(first.player);
fixture.awaitCharacterAndReceipt(first);
first.core.inventory.setItemInOffHand(null);
second.core.inventory.setItemInOffHand(sheet);
fixture.activate(second);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
var secondSession = sessions.active(second.core.id).orElseThrow().session();
assertEquals(first.core.record.id(), sessions.active(second.core.id).orElseThrow().character());
sessions.exit(second.player);
fixture.awaitCharacterAndReceipt(second);
fixture.reenter(second);
assertEquals(first.core.record.id(), sessions.active(second.core.id).orElseThrow().character());
fixture.plugin.setEnabled(false);
var transfers = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.transfer_observed")).toList();
assertEquals(1, transfers.stream().map(entry -> entry.get("id").getAsString()).distinct().count());
var values = transfers.getFirst().getAsJsonObject("data");
assertEquals(first.core.id.toString(), values.get("from_player_id").getAsString());
assertEquals(second.core.id.toString(), values.get("to_player_id").getAsString());
assertEquals(firstSession.toString(), values.get("from_session_id").getAsString());
assertEquals(secondSession.toString(), values.get("to_session_id").getAsString());
assertEquals("activation_after_prior_holder", values.get("custody_quality").getAsString());
assertEquals(first.world.getUID().toString(), values.getAsJsonObject("from_location")
.get("world_id").getAsString());
assertEquals("cached_at_previous_activation", values.getAsJsonObject("from_location")
.get("quality").getAsString());
assertEquals(world.getUID().toString(), values.getAsJsonObject("to_location")
.get("world_id").getAsString());
assertEquals(world.getUID().toString(), values.getAsJsonObject("location").get("world_id").getAsString());
}
}
@Test void firstActivationAfterRestartDoesNotInventUnseenSheetTransfer() throws Exception {
org.bukkit.inventory.ItemStack transferred;
try (var firstBoot = new FantasyStickLifecycleNativeTest.Fixture(data)) {
firstBoot.plugin.setEnabled(true);
var owner = new FantasyStickLifecycleNativeTest.Actor(data);
firstBoot.players.put(owner.core.id, owner.player);
firstBoot.activate(owner);
transferred = owner.core.inventory.getItemInOffHand().clone();
((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(firstBoot.plugin, "sessions")).exit(owner.player);
firstBoot.awaitCharacterAndReceipt(owner);
firstBoot.plugin.setEnabled(false);
}
try (var secondBoot = new FantasyStickLifecycleNativeTest.Fixture(data)) {
secondBoot.plugin.setEnabled(true);
var newHolder = new FantasyStickLifecycleNativeTest.Actor(data);
secondBoot.players.put(newHolder.core.id, newHolder.player);
newHolder.core.inventory.setItemInOffHand(transferred);
secondBoot.activate(newHolder);
secondBoot.plugin.setEnabled(false);
}
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("character.transfer_observed")), "Offline custody was not seen by this plugin lifetime");
}
@Test void staleGenerationAndSaveFailureResolveAnvilAttemptsWithoutRenameSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var repository = new YamlCharacterRepository(data);
var original = new games.dmg.fantasystick.crafting.CharacterFactory()
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
repository.create(original);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
var operation = UUID.randomUUID();
storage.beginRecovery(new games.dmg.fantasystick.persistence.CharacterStorage.RecoveryRequest(
operation, original.id(), UUID.randomUUID(), UUID.randomUUID(), original.revision(),
original.sheetGeneration(), false, null, java.math.BigDecimal.valueOf(20))).join();
storage.completeRecovery(original.id(), operation).join();
var anvil = new CharacterAnvilNamesNativeTest.AnvilFixture();
var stale = new CharacterSheets().create(original);
anvil.grid.setItem(0, stale);
var result = CharacterAnvilNamesNativeTest.named(stale,
net.kyori.adventure.text.Component.text("forged revision 雪"));
anvil.grid.setItem(2, result);
fixture.dispatcher.accept(new org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent(
anvil.actor.player, anvil.view, result));
storage.find(original.id()).join();
var second = new games.dmg.fantasystick.crafting.CharacterFactory()
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
repository.create(second);
var live = new CharacterSheets().create(second);
anvil.grid.setItem(0, live);
var attempted = CharacterAnvilNamesNativeTest.named(live,
net.kyori.adventure.text.Component.text("not persisted"));
anvil.grid.setItem(2, attempted);
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),
java.nio.file.StandardOpenOption.WRITE); var lock = channel.lock()) {
assertTrue(lock.isValid());
fixture.dispatcher.accept(new org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent(
anvil.actor.player, anvil.view, attempted));
storage.find(second.id()).handle((ignored, failure) -> null).join();
}
for (int i = 0; i < 100 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved")).count() < 2; i++) Thread.sleep(10);
fixture.plugin.setEnabled(false);
assertEquals(second.name(), repository.load(second.id()).orElseThrow().name());
var entries = records();
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("character.renamed")));
var resolved = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved")).map(entry -> entry.getAsJsonObject("data"))
.filter(value -> value.has("action_kind") && value.get("action_kind").getAsString()
.equals("character_rename")).toList();
assertTrue(resolved.stream().anyMatch(value -> value.get("reason").getAsString().equals("stale_generation")));
assertTrue(resolved.stream().anyMatch(value -> value.get("reason").getAsString().equals("save_failed")));
}
}
private List<JsonObject> records() throws Exception {
var results = new ArrayList<JsonObject>();
try (var dates = Files.list(data.resolve("events"))) {
for (var date : dates.filter(path -> path.getFileName().toString().matches("[0-9]{4}-[0-9]{2}-[0-9]{2}"))
.toList()) {
try (var files = Files.list(date)) {
for (var file : files.filter(path -> path.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(file);
var complete = content.lastIndexOf('\n');
if (complete < 0) continue;
for (var line : content.substring(0, complete).lines().toList())
if (!line.isBlank()) results.add(JsonParser.parseString(line).getAsJsonObject());
}
}
}
}
return results;
}
}
@@ -61,8 +61,12 @@ class DownedSessionNativeTest{
}
@Test void deathMenuAcceptanceFinalizesTheSameEpisodeAndDeliversOneTail()throws Exception{
games.dmg.fantasystick.party.CharacterParty finalParty;
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);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var persistentParties=PartyLifecycleNativeTest.directory(f);finalParty=persistentParties.create(actor.core.record.id(),"Finalists").join().party().orElseThrow();var retainedPhoenix=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");retainedPhoenix.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();assertEquals(finalParty.id(),persistentParties.partyOf(actor.core.record.id()).orElseThrow().id());var menu=actor.inventories.getLast();assertTrue(plain(menu.getItem(11)).contains("Accept Death"));assertTrue(plain(menu.getItem(13)).contains("Phoenix Down"));assertTrue(plain(menu.getItem(15)).contains("Wait for Revive"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(11);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}PartyLifecycleNativeTest.directory(f).defeat(UUID.randomUUID()).join();f.awaitCharacterAndReceipt(actor);PartyBookNativeTest.drain(f);var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(dead.dead());assertEquals(episode.id(),dead.deathId());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,directory.find(dead.id()).join().orElseThrow().outcome());for(int attempt=0;attempt<80&&!directory.find(dead.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(dead.id()).join().orElseThrow().published());assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertTrue(persistentParties.partyOf(actor.core.record.id()).isEmpty());}
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.getFileName().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());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().contains(".party.")).count());var ended=events.stream().filter(event->event.get("type").getAsString().endsWith("party.disbanded")&&event.getAsJsonObject("data").has("reason")&&event.getAsJsonObject("data").get("reason").getAsString().equals("final_death")).toList();assertFalse(ended.isEmpty());assertEquals(1,ended.stream().map(event->event.get("id").getAsString()).distinct().count());assertEquals(finalParty.id().toString(),ended.getFirst().getAsJsonObject("data").get("party_id").getAsString());
games.dmg.fantasystick.events.SwitchDelayEventBridge switchAudit;
UUID exitedSession;
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);switchAudit=(games.dmg.fantasystick.events.SwitchDelayEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"switchAuditEvents");var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var persistentParties=PartyLifecycleNativeTest.directory(f);finalParty=persistentParties.create(actor.core.record.id(),"Finalists").join().party().orElseThrow();var retainedPhoenix=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");retainedPhoenix.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();exitedSession=episode.session();assertEquals(finalParty.id(),persistentParties.partyOf(actor.core.record.id()).orElseThrow().id());var menu=actor.inventories.getLast();assertTrue(plain(menu.getItem(11)).contains("Accept Death"));assertTrue(plain(menu.getItem(13)).contains("Phoenix Down"));assertTrue(plain(menu.getItem(15)).contains("Wait for Revive"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(11);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}PartyLifecycleNativeTest.directory(f).defeat(UUID.randomUUID()).join();f.awaitCharacterAndReceipt(actor);PartyBookNativeTest.drain(f);var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(dead.dead());assertEquals(episode.id(),dead.deathId());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,directory.find(dead.id()).join().orElseThrow().outcome());for(int attempt=0;attempt<80&&!directory.find(dead.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(dead.id()).join().orElseThrow().published());assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertTrue(persistentParties.partyOf(actor.core.record.id()).isEmpty());}
switchAudit.drained().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
try(var restarted=new FantasyStickLifecycleNativeTest.Fixture(data)){restarted.plugin.setEnabled(true);for(int i=0;i<100&&!java.nio.file.Files.exists(data.resolve("events"));i++)Thread.sleep(10);for(int i=0;i<100;i++){boolean found=false;try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())if(java.nio.file.Files.readString(file).contains("character.switch_delay_started")){found=true;break;}}if(found)break;Thread.sleep(10);}}
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.getFileName().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());}var switchStarts=events.stream().filter(event->event.get("type").getAsString().endsWith("character.switch_delay_started")).filter(event->event.getAsJsonObject("data").get("session_id").getAsString().equals(exitedSession.toString())).toList();assertEquals(1,switchStarts.stream().map(event->event.get("id").getAsString()).distinct().count());assertEquals("defeat",switchStarts.getFirst().getAsJsonObject("data").get("exit_reason").getAsString());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().contains(".party.")).count());var ended=events.stream().filter(event->event.get("type").getAsString().endsWith("party.disbanded")&&event.getAsJsonObject("data").has("reason")&&event.getAsJsonObject("data").get("reason").getAsString().equals("final_death")).toList();assertFalse(ended.isEmpty());assertEquals(1,ended.stream().map(event->event.get("id").getAsString()).distinct().count());assertEquals(finalParty.id().toString(),ended.getFirst().getAsJsonObject("data").get("party_id").getAsString());
}
@Test void explicitPhoenixDownChoiceConsumesOneAndRescuesTheExactDownedCharacter()throws Exception{
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);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var inventory=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");inventory.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();inventory.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();PartyBookNativeTest.open(f,actor);String inventoryText=PartyBookNativeTest.text(actor);assertTrue(inventoryText.contains("Phoenix Down: 2"),inventoryText);assertTrue(inventoryText.contains("Use Phoenix Down"),inventoryText);assertFalse(inventoryText.contains("[Use]"),inventoryText);actor.core.health=0;var death=death(actor);f.dispatcher.accept(death);var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var menu=actor.inventories.getLast();assertEquals(org.bukkit.Material.FEATHER,menu.getItem(13).getType());assertTrue(plain(menu.getItem(13)).contains("Use Phoenix Down"));assertTrue(plain(menu.getItem(13)).contains("2"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(13);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<160&&(!directory.find(actor.core.record.id()).join().orElseThrow().published()||inventory.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN).compareTo(java.math.BigInteger.ONE)!=0||actor.player.getHealth()!=actor.maximumHealth.getValue());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var resolved=directory.find(actor.core.record.id()).join().orElseThrow();assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.REVIVED,resolved.outcome());assertEquals("phoenix_down",resolved.decision().source());assertEquals(episode.id(),resolved.id());assertEquals(java.math.BigInteger.ONE,inventory.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).downedProtected(actor.core.id));assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());var protectedDamage=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageEvent.class);org.mockito.Mockito.when(protectedDamage.getEntity()).thenReturn(actor.player);org.mockito.Mockito.when(protectedDamage.getFinalDamage()).thenReturn(3d);controller.damage(protectedDamage);org.mockito.Mockito.verify(protectedDamage).setCancelled(true);f.plugin.setEnabled(false);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.getFileName().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());}var phoenixEvents=events.stream().filter(event->event.get("type").getAsString().endsWith("character.downed_resolved")&&event.getAsJsonObject("data").get("outcome").getAsString().equals("phoenix_down")).toList();assertFalse(phoenixEvents.isEmpty());assertEquals(1,phoenixEvents.stream().map(event->event.get("id").getAsString()).distinct().count());var phoenixData=phoenixEvents.getFirst().getAsJsonObject("data");assertEquals("downed_menu",phoenixData.get("selection_entry_point").getAsString());assertEquals("2",phoenixData.get("quantity_before").getAsString());assertEquals("1",phoenixData.get("quantity_after").getAsString());assertEquals("10.0",phoenixData.get("maximum_health").getAsString());assertTrue(events.stream().filter(event->event.get("type").getLine truncated
@@ -0,0 +1,337 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.file.Files;
import java.nio.file.Path;
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;
class EquipmentEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void acknowledgedNativePackAndExactRestorationProduceOwnJsonlWithoutDuplicatingSessionEvents() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
var active = sessions.active(actor.core.id).orElseThrow();
var pending = fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(active.session(), pending.pending().sessionId());
sessions.exit(actor.player);
fixture.awaitCharacterAndReceipt(actor);
var completed = fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow();
assertNull(completed.pending());
awaitDomain(fixture, "equipment.packed", "equipment.restored");
fixture.plugin.setEnabled(false);
var entries = records();
var packed = entries.stream().filter(event -> event.get("type").getAsString()
.endsWith("equipment.packed")).toList();
var restored = entries.stream().filter(event -> event.get("type").getAsString()
.endsWith("equipment.restored")).toList();
assertEquals(1, packed.stream().map(event -> event.get("id").getAsString()).distinct().count());
assertEquals(1, restored.stream().map(event -> event.get("id").getAsString()).distinct().count());
assertEquals(active.session().toString(), packed.getFirst().getAsJsonObject("data")
.get("session_id").getAsString());
assertEquals(active.session().toString(), restored.getFirst().getAsJsonObject("data")
.get("session_id").getAsString());
assertEquals(pending.revision().toString(), packed.getFirst().getAsJsonObject("data")
.get("receipt_revision").getAsString());
assertEquals(completed.revision().toString(), restored.getFirst().getAsJsonObject("data")
.get("receipt_revision").getAsString());
assertEquals("main", restored.getFirst().getAsJsonObject("data").get("box_source").getAsString());
assertEquals("none", restored.getFirst().getAsJsonObject("data")
.get("displaced_head_handling").getAsString());
assertTrue(packed.getFirst().getAsJsonObject("data").get("slot_classes").getAsJsonArray().size() >= 1);
assertEquals(1, entries.stream().filter(event -> event.get("type").getAsString()
.endsWith("character.entered")).map(event -> event.get("id").getAsString()).distinct().count());
assertEquals(1, entries.stream().filter(event -> event.get("type").getAsString()
.endsWith("character.exited")).map(event -> event.get("id").getAsString()).distinct().count());
}
}
@Test void failedReceiptCompletionCannotTurnNativeRestoreIntoAcknowledgedEvent() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
awaitDomain(fixture, "equipment.packed");
var receipt = data.resolve("sessions").resolve(actor.core.id + ".yml");
var backup = data.resolve("receipt-backup.yml");
Files.move(receipt, backup);
Files.createDirectory(receipt);
Files.writeString(receipt.resolve("block"), "not a receipt");
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,
sessions.exit(actor.player));
((games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"))
.find(actor.core.record.id()).get(5, TimeUnit.SECONDS);
for (int i = 0; i < 4; i++) {
var callback = fixture.tasks.poll(300, TimeUnit.MILLISECONDS);
if (callback == null) break;
callback.run();
}
assertThrows(java.util.concurrent.ExecutionException.class, () -> fixture.receipts().find(actor.core.id)
.toCompletableFuture().get(5, TimeUnit.SECONDS));
Files.delete(receipt.resolve("block"));
Files.delete(receipt);
Files.move(backup, receipt);
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
fixture.plugin.setEnabled(false);
fixture.awaitWorkers();
assertTrue(records().stream().noneMatch(event -> event.get("type").getAsString()
.endsWith("equipment.restored")));
}
}
@Test void acknowledgedRestorationKeepsOriginalIdAcrossClosedWriterAndRestart() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
awaitDomain(fixture, "equipment.packed");
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
var session = sessions.active(actor.core.id).orElseThrow().session();
sessions.exit(actor.player);
fixture.awaitCharacterAndReceipt(actor);
fixture.plugin.setEnabled(false);
fixture.awaitWorkers();
java.util.Set<String> pendingIds;
try (var files = Files.list(data.resolve("equipment-event-intents"))) {
pendingIds = files.filter(path -> path.getFileName().toString().endsWith(".pending"))
.map(path -> path.getFileName().toString().replace(".pending", ""))
.collect(java.util.stream.Collectors.toSet());
}
assertEquals(1, pendingIds.size());
fixture.plugin.setEnabled(true);
awaitDomain(fixture, "equipment.restored");
fixture.plugin.setEnabled(false);
var domain = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")).toList();
assertEquals(pendingIds, domain.stream().map(entry -> entry.get("id").getAsString())
.collect(java.util.stream.Collectors.toSet()));
assertTrue(domain.stream().allMatch(entry -> entry.getAsJsonObject("data")
.get("session_id").getAsString().equals(session.toString())));
}
}
@Test void cursorHeldGenuineBoxAndDisplacedHeadAreCapturedWithoutItemContents() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
actor.core.cursor = actor.core.inventory.getItem(9).clone();
actor.core.inventory.setItem(9, null);
actor.core.inventory.setHelmet(new org.bukkit.inventory.ItemStack(org.bukkit.Material.CARVED_PUMPKIN));
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,
sessions.exit(actor.player));
fixture.awaitCharacterAndReceipt(actor);
assertEquals(org.bukkit.Material.CARVED_PUMPKIN, actor.core.cursor.getType());
awaitDomain(fixture, "equipment.packed", "equipment.restored");
fixture.plugin.setEnabled(false);
var restored = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("cursor", restored.get("box_source").getAsString());
assertEquals("cursor", restored.get("displaced_head_handling").getAsString());
assertFalse(restored.toString().contains("CARVED_PUMPKIN"));
assertFalse(restored.toString().contains("DIAMOND_SWORD"));
}
}
@Test void restartedArrivalDoesNotInventHealthAcknowledgmentOrCompletedRestore() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
var session = sessions.active(actor.core.id).orElseThrow().session();
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);
awaitDomain(fixture, "equipment.packed");
fixture.plugin.setEnabled(false);
fixture.awaitWorkers();
actor.core.inventory.setItem(0, null);
fixture.plugin.setEnabled(true);
fixture.tick();
fixture.awaitCharacterAndReceipt(actor);
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
awaitDomain(fixture, "equipment.recovery_resolved");
fixture.plugin.setEnabled(false);
var entries = records();
assertEquals(0, entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")).count());
var recovery = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("equipment.recovery_resolved")).findFirst().orElseThrow();
var value = recovery.getAsJsonObject("data");
assertEquals(session.toString(), value.get("session_id").getAsString());
assertEquals("packed", value.get("prior_phase").getAsString());
assertEquals("restored", value.get("current_phase").getAsString());
assertEquals("native_restored_receipt_pending", value.get("custody_quality").getAsString());
assertEquals("ambiguous", value.get("outcome").getAsString());
assertEquals("health_unverified", value.get("reason").getAsString());
assertTrue(value.has("location"));
}
}
@Test void uncertainCursorWriteReportsAmbiguousRecoveryWithoutFalseRestore() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
actor.core.cursor = actor.core.inventory.getItem(9).clone();
actor.core.inventory.setItem(9, null);
actor.core.beforeCursorMutation = () -> { throw new IllegalStateException("Native cursor write uncertain"); };
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.INVALID_BOX,
sessions.exit(actor.player));
fixture.awaitCharacterAndReceipt(actor);
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
actor.core.beforeCursorMutation = () -> {};
sessions.recover(actor.player);
assertTrue(fixture.tasks.isEmpty(), "Uncertain native writes cannot be replayed automatically");
awaitAction(fixture, "equipment_restore", "uncertain");
fixture.plugin.setEnabled(false);
var entries = records();
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")));
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
.map(entry -> entry.getAsJsonObject("data"))
.anyMatch(value -> value.has("action_kind") && value.get("action_kind").getAsString()
.equals("equipment_restore") && value.get("outcome").getAsString().equals("uncertain")));
}
}
@Test void ambiguousNativeBoxCopiesRetainReceiptWithoutInventingRestoration() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
actor.core.cursor = actor.core.inventory.getItem(9).clone();
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.AMBIGUOUS,
sessions.exit(actor.player));
fixture.awaitCharacterAndReceipt(actor);
sessions.recover(actor.player);
fixture.tick();
fixture.awaitCharacterAndReceipt(actor);
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
awaitDomain(fixture, "equipment.recovery_resolved");
fixture.plugin.setEnabled(false);
var entries = records();
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")));
var recovery = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("equipment.recovery_resolved")).toList();
assertEquals(1, recovery.stream().map(entry -> entry.get("id").getAsString()).distinct().count());
assertEquals("retained", recovery.getFirst().getAsJsonObject("data").get("outcome").getAsString());
assertEquals("ambiguous", recovery.getFirst().getAsJsonObject("data").get("reason").getAsString());
}
}
@Test void refusedOccupiedSlotThenProofBasedRecoveryReportsRetainedAndRestoredOutcomes() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
var session = sessions.active(actor.core.id).orElseThrow().session();
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);
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
actor.core.inventory.setItem(0, null);
sessions.recover(actor.player);
fixture.tick();
fixture.awaitCharacterAndReceipt(actor);
assertNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
awaitDomain(fixture, "equipment.packed", "equipment.restored", "equipment.recovery_resolved");
fixture.plugin.setEnabled(false);
var entries = records();
var recovery = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("equipment.recovery_resolved")).toList();
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved")).map(entry -> entry.getAsJsonObject("data"))
.anyMatch(value -> value.has("action_kind") && value.get("action_kind").getAsString()
.equals("equipment_restore") && value.get("reason").getAsString().equals("occupied_slot")));
assertTrue(recovery.stream().noneMatch(entry -> entry.getAsJsonObject("data").get("outcome")
.getAsString().equals("retained")), "Initial refusal is not a second recovery attempt");
assertTrue(recovery.stream().anyMatch(entry -> entry.getAsJsonObject("data").get("outcome")
.getAsString().equals("restored") && entry.getAsJsonObject("data").get("session_id")
.getAsString().equals(session.toString())));
assertEquals(1, entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")).map(entry -> entry.get("id").getAsString()).distinct().count());
}
}
private void awaitAction(FantasyStickLifecycleNativeTest.Fixture fixture, String kind, String outcome) throws Exception {
for (int i = 0; i < 300; i++) {
var callback = fixture.tasks.poll(20, TimeUnit.MILLISECONDS);
if (callback != null) callback.run();
if (records().stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
.map(entry -> entry.getAsJsonObject("data"))
.anyMatch(data -> data.has("action_kind") && data.get("action_kind").getAsString().equals(kind)
&& data.get("outcome").getAsString().equals(outcome))) return;
}
fail("Action resolution did not reach JSONL before shutdown");
}
private void awaitDomain(FantasyStickLifecycleNativeTest.Fixture fixture, String... types) throws Exception {
for (int i = 0; i < 300; i++) {
var callback = fixture.tasks.poll(20, TimeUnit.MILLISECONDS);
if (callback != null) callback.run();
var entries = records();
if (java.util.Arrays.stream(types).allMatch(type -> entries.stream()
.anyMatch(entry -> entry.get("type").getAsString().endsWith(type)))) return;
}
fail("Accepted equipment events did not reach JSONL before shutdown");
}
private List<JsonObject> records() throws Exception {
var result = new ArrayList<JsonObject>();
try (var dates = Files.list(data.resolve("events"))) {
for (var date : dates.filter(path -> path.getFileName().toString().matches("[0-9]{4}-[0-9]{2}-[0-9]{2}"))
.toList()) try (var files = Files.list(date)) {
for (var file : files.filter(path -> path.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(file);
int complete = content.lastIndexOf('\n');
if (complete < 0) continue;
for (var line : content.substring(0, complete).lines().toList())
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
}
}
}
return result;
}
}
@@ -42,7 +42,9 @@ class FantasyFeedbackCommandNativeTest {
assertEquals(2, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback"))
.map(e -> e.get("id").getAsString()).distinct().count(),
"At-least-once file export may repeat a line, not a logical preference change");
}
@Test void consoleMalformedAndExtraArgumentsCannotChangeAnAccountPreference() throws Exception {
@@ -149,8 +149,13 @@ class FantasyStickLifecycleNativeTest {
assertTrue(records.contains("\"item_kind\":\"control\""));
assertTrue(records.contains("\"control_kind\":\"book\""));
assertTrue(records.contains("\"guard_reason\":\"protected_item\""));
assertEquals(1, records.split("games.dmg.minecraft.fantasystick.action.observed", -1).length - 1,
"The duplicate offhand callback must not create another logical observation");
assertEquals(1, records.lines().filter(line -> !line.isBlank())
.map(line -> com.google.gson.JsonParser.parseString(line).getAsJsonObject())
.filter(entry -> entry.get("type").getAsString().endsWith("action.observed"))
.map(entry -> entry.getAsJsonObject("data"))
.filter(details -> details.has("guard_reason")
&& details.get("guard_reason").getAsString().equals("protected_item"))
.count(), "The duplicate offhand callback must not create another logical observation");
}
}
@@ -431,6 +436,23 @@ class FantasyStickLifecycleNativeTest {
if (route >= 4) assertEquals(quantity, world.spawnCalls);
complete.run();
assertEquals(1, actor.syncs);
java.util.List<com.google.gson.JsonObject> created = java.util.List.of();
for (int attempt = 0; attempt < 100; attempt++) {
created = localRecords(directory).stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.created")).toList();
if (created.size() == quantity) break;
Thread.sleep(10);
}
assertEquals(quantity, created.stream().map(entry -> entry.get("id").getAsString()).distinct().count());
assertEquals(java.util.stream.IntStream.range(0, quantity).boxed().collect(java.util.stream.Collectors.toSet()),
created.stream().map(entry -> entry.getAsJsonObject("data").get("index").getAsInt())
.collect(java.util.stream.Collectors.toSet()));
assertEquals(1, created.stream().map(entry -> entry.getAsJsonObject("data").get("action_id").getAsString())
.distinct().count(), "All outputs share their actual craft action");
assertEquals(new java.util.HashSet<>(ids), created.stream().map(entry ->
java.util.UUID.fromString(entry.getAsJsonObject("data").get("character_id").getAsString()))
.collect(java.util.stream.Collectors.toSet()));
assertTrue(created.stream().allMatch(entry -> entry.getAsJsonObject("data").get("quantity").getAsInt() == quantity));
}
}
}
@@ -580,13 +602,17 @@ class FantasyStickLifecycleNativeTest {
@Test void packagedRemovalSwapQuitAndDisableRestoreExactlyOnce() throws Exception {
for (String route : List.of("removal", "swap", "quit", "disable")) {
java.util.UUID exitedSession;
games.dmg.fantasystick.events.SwitchDelayEventBridge audit;
try (var f = new Fixture(data.resolve(route))) {
f.plugin.setEnabled(true);
var a = new Actor(data.resolve(route)); f.players.put(a.core.id, a.player);
var viewer = f.viewer(a, data.resolve(route)); f.activate(a);
audit = (games.dmg.fantasystick.events.SwitchDelayEventBridge) Fixture.field(f.plugin, "switchAuditEvents");
assertNativeName(f, a, viewer, a.core.record.name()); assertChat(f, a, a.core.record.name());
var receipts = f.receipts();
var session = receipts.find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending();
exitedSession = session.sessionId();
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
var worker = (java.util.concurrent.ExecutorService) Fixture.field(characters, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
@@ -643,11 +669,46 @@ class FantasyStickLifecycleNativeTest {
}
} finally { release.countDown(); }
}
audit.drained().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
Path directory = data.resolve(route);
if (route.equals("disable")) {
var intent = directory.resolve("switch-event-intents").resolve(exitedSession + ".pending");
assertTrue(java.nio.file.Files.exists(intent), "Shutdown exit intent survives closed event writer");
}
try (var restarted = new Fixture(directory)) {
restarted.plugin.setEnabled(true);
for (int i = 0; i < 100 && localRecords(directory).stream()
.noneMatch(entry -> entry.get("type").getAsString().endsWith("character.switch_delay_started")); i++)
Thread.sleep(10);
}
var events = localRecords(directory);
var starts = events.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.switch_delay_started"))
.filter(entry -> entry.getAsJsonObject("data").get("session_id").getAsString()
.equals(exitedSession.toString())).toList();
assertEquals(1, starts.stream().map(entry -> entry.get("id").getAsString()).distinct().count(), route);
var start = starts.getFirst().getAsJsonObject("data");
assertEquals(route.equals("quit") ? "logout" : route.equals("disable") ? "shutdown" : "voluntary",
start.get("exit_reason").getAsString());
assertEquals("900", start.get("duration_seconds").getAsString());
assertEquals(starts.getFirst().get("time").getAsString(), start.get("exited_at").getAsString());
}
}
private static java.util.List<com.google.gson.JsonObject> localRecords(Path directory) throws IOException {
var events = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(directory.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return events;
}
@Test void packagedRefusalsExplainSpaceAndIneligibleEquipmentWithoutMutation() throws Exception {
for (boolean full : new boolean[]{false, true}) try (var f = new Fixture(data.resolve("refusal-" + full))) {
for (boolean full : new boolean[]{false, true}) {
Path directory = data.resolve("refusal-" + full);
try (var f = new Fixture(directory)) {
f.plugin.setEnabled(true);
var a = new Actor(data.resolve("refusal-" + full)); f.players.put(a.core.id, a.player);
if (full) for (int slot = 9; slot < 36; slot++) a.core.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE, 64));
@@ -658,6 +719,25 @@ class FantasyStickLifecycleNativeTest {
assertTrue(a.messages.getFirst().contains(full ? "free one slot" : "vanilla shulker"), a.messages::toString);
assertEquals(1, a.particles.size()); assertTrue(a.particles.getFirst() > 0 && a.particles.getFirst() <= 16);
assertTrue(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).isEmpty());
boolean actionWritten = false;
for (int i = 0; i < 300; i++) {
if (localRecords(directory).stream().anyMatch(entry -> entry.get("type").getAsString().endsWith("action.resolved")
&& entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("equipment_pack_preflight"))) {
actionWritten = true; break;
}
Thread.sleep(20);
}
assertTrue(actionWritten, "Preflight refusal must reach local JSONL");
}
var entries = localRecords(directory);
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString().endsWith("equipment.packed")));
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
.map(entry -> entry.getAsJsonObject("data"))
.anyMatch(value -> value.has("action_kind") && value.get("action_kind").getAsString()
.equals("equipment_pack_preflight") && value.get("reason").getAsString()
.equals(full ? "no_space" : "ineligible_equipment") && value.get("outcome").getAsString()
.equals("refused")));
}
}
@@ -1013,6 +1093,21 @@ class FantasyStickLifecycleNativeTest {
}
} finally { release.countDown(); }
}
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(directory.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
var started = records.stream().filter(e -> e.get("type").getAsString().endsWith("character.switch_delay_started"))
.map(e -> e.getAsJsonObject("data")).findFirst().orElseThrow();
assertEquals(value.equals("default") ? "900" : value, started.get("duration_seconds").getAsString());
assertEquals("logout", started.get("exit_reason").getAsString());
assertEquals(value.equals("0"), started.get("zero_duration").getAsBoolean());
assertTrue(records.stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("action_kind")
&& e.getAsJsonObject("data").get("action_kind").getAsString().equals("character_switch_preflight")
&& e.getAsJsonObject("data").get("outcome").getAsString().equals(value.equals("0") ? "allowed" : "refused")));
}
}
@@ -1483,6 +1578,16 @@ class FantasyStickLifecycleNativeTest {
try {
for (var worker : workers) assertTrue(worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS),
"Accepted persistence must drain before fixture disposal; production shutdown is nonblocking");
((java.util.concurrent.CompletionStage<?>) field(plugin, "switchAuditDrain"))
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
((java.util.concurrent.CompletionStage<?>) field(plugin, "identityAuditDrain"))
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
((java.util.concurrent.CompletionStage<?>) field(plugin, "equipmentAuditDrain"))
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
((java.util.concurrent.CompletionStage<?>) field(plugin, "sessionRecoveryAuditDrain"))
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
} catch (java.util.concurrent.ExecutionException | java.util.concurrent.TimeoutException failure) {
throw new IOException("Switch event intent worker did not drain before fixture disposal", failure);
} catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IOException(interrupted); }
}
@Override public void close() throws IOException {
@@ -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());
@@ -438,6 +438,7 @@ class ManualCraftingControllerNativeTest {
case "getUniqueId" -> id;
case "getOpenInventory" -> current;
case "getEyeLocation" -> java.util.Objects.requireNonNull(eyeLocation).clone();
case "getLocation" -> eyeLocation == null ? null : eyeLocation.clone();
case "isOnline" -> online;
case "updateInventory" -> { syncs++; onSync.run(); yield null; }
case "getName" -> "CraftingActor";
@@ -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");
@@ -0,0 +1,393 @@
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;
/** Current persistent-party native damage decision -> generic local JSONL action. */
class PartyProtectionEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void currentCharacterPartyNativeDirectDamageRefusalHasOneCorrelatedFinalDecision() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(attacker.core.id).orElseThrow();
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
var damage = PartyCombatNativeTest.hit(fixture, attacker.player, target.player);
assertTrue(damage.isCancelled());
for (int i = 0; i < 250 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("same_party")); i++)
Thread.sleep(20);
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("combat.damage")), "A protected hit cannot become an accepted damage record");
assertTrue(parties.leave(target.core.id, group.id()));
assertFalse(PartyCombatNativeTest.hit(fixture, attacker.player, target.player).isCancelled());
fixture.plugin.setEnabled(false);
var entries = records();
var observed = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.observed") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.toList();
var resolved = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("same_party"))
.toList();
assertEquals(1, observed.size()); assertEquals(1, resolved.size());
assertEquals(observed.getFirst().getAsJsonObject("data").get("action_id").getAsString(),
resolved.getFirst().getAsJsonObject("data").get("action_id").getAsString());
var value = resolved.getFirst().getAsJsonObject("data");
assertEquals(group.id().toString(), value.get("source_party_id").getAsString());
assertEquals(group.id().toString(), value.get("target_party_id").getAsString());
assertEquals("refused", value.get("outcome").getAsString());
assertTrue(value.has("source_location")); assertTrue(value.has("target_location"));
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("party.created")), "Protection cannot resurrect transient lifecycle records");
}
}
@Test void observationDoesNotReconcileNativeOngoingProvenanceBeforeProtectionPriority() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
org.mockito.Mockito.doReturn(effect).when(target.player).getPotionEffect(effect.getType());
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityPotionEffectEvent(target.player,
null, effect, attacker.player, org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, true));
var before = target.core.pdc.serializeToBytes();
org.mockito.Mockito.doReturn(null).when(target.player).getPotionEffect(effect.getType());
var atNormalPriority = new org.bukkit.event.Listener() {
byte[] observed;
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.NORMAL)
public void inspect(org.bukkit.event.entity.EntityDamageEvent ignored) {
observed = assertDoesNotThrow(() -> target.core.pdc.serializeToBytes());
}
};
fixture.dispatcher.register(atNormalPriority, fixture.plugin);
var tick = new org.bukkit.event.entity.EntityDamageEvent(target.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
fixture.dispatcher.accept(tick);
assertNotNull(atNormalPriority.observed, "NORMAL-priority observer must execute");
assertArrayEquals(before, atNormalPriority.observed,
"Audit must not reconcile native effect PDC earlier than its protection guard");
assertFalse(tick.isCancelled());
assertFalse(java.util.Arrays.equals(before, target.core.pdc.serializeToBytes()),
"Original HIGHEST protection read still reconciles an ended effect");
}
}
@Test void poisonWitherAndFireTickRecheckBoundSourceWithoutInventedNativeAttacker() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
for (var type : List.of(org.bukkit.potion.PotionEffectType.POISON,
org.bukkit.potion.PotionEffectType.WITHER)) {
var effect = new org.bukkit.potion.PotionEffect(type, 200, 0);
org.mockito.Mockito.doReturn(effect).when(target.player).getPotionEffect(type);
var apply = new org.bukkit.event.entity.EntityPotionEffectEvent(target.player, null, effect,
attacker.player, org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, true);
fixture.dispatcher.accept(apply);
assertFalse(apply.isCancelled());
}
org.mockito.Mockito.doReturn(200).when(target.player).getFireTicks();
var ignite = new org.bukkit.event.entity.EntityCombustByEntityEvent(attacker.player, target.player, 10.0f);
fixture.dispatcher.accept(ignite); assertFalse(ignite.isCancelled());
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(attacker.core.id).orElseThrow();
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
var beforeVeto = target.core.pdc.serializeToBytes();
org.mockito.Mockito.doReturn(null).when(target.player).getPotionEffect(org.bukkit.potion.PotionEffectType.POISON);
var vetoedTick = new org.bukkit.event.entity.EntityDamageEvent(target.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
vetoedTick.setCancelled(true); fixture.dispatcher.accept(vetoedTick);
assertArrayEquals(beforeVeto, target.core.pdc.serializeToBytes(),
"A prior veto must not reconcile or clear owned native provenance");
org.mockito.Mockito.doReturn(new org.bukkit.potion.PotionEffect(
org.bukkit.potion.PotionEffectType.POISON, 200, 0)).when(target.player)
.getPotionEffect(org.bukkit.potion.PotionEffectType.POISON);
for (var cause : List.of(org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.WITHER,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE_TICK)) {
var tick = new org.bukkit.event.entity.EntityDamageEvent(target.player, cause,
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
fixture.dispatcher.accept(tick);
assertTrue(tick.isCancelled(), cause.toString());
}
for (var type : List.of(org.bukkit.potion.PotionEffectType.POISON,
org.bukkit.potion.PotionEffectType.WITHER)) {
var remove = new org.bukkit.event.entity.EntityPotionEffectEvent(target.player,
new org.bukkit.potion.PotionEffect(type, 200, 0), null, null,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
fixture.dispatcher.accept(remove);
org.mockito.Mockito.doReturn(null).when(target.player).getPotionEffect(type);
}
org.mockito.Mockito.doReturn(0).when(target.player).getFireTicks();
for (var cause : List.of(org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.WITHER,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE_TICK)) {
var expired = new org.bukkit.event.entity.EntityDamageEvent(target.player, cause,
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
fixture.dispatcher.accept(expired);
assertFalse(expired.isCancelled(), "Expired provenance cannot create party protection: " + cause);
}
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.count() < 3; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var decisions = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.map(entry -> entry.getAsJsonObject("data")).toList();
assertEquals(3, decisions.size());
assertEquals(java.util.Set.of("poison", "wither", "fire_tick"), decisions.stream()
.map(value -> value.get("damage_cause").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertTrue(decisions.stream().allMatch(value -> value.get("reason").getAsString().equals("same_party")
&& value.get("source_location_status").getAsString().equals("unavailable")
&& value.has("target_location")));
}
}
@Test void mixedModeAndPriorCancelledHitsReportTruthfulDistinctOutcomes() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker);
var mixed = PartyCombatNativeTest.hit(fixture, attacker.player, target.player);
assertTrue(mixed.isCancelled());
fixture.activate(target);
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(attacker.core.id).orElseThrow();
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
var source = org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class);
org.mockito.Mockito.when(source.getCausingEntity()).thenReturn(attacker.player);
org.mockito.Mockito.when(source.getDirectEntity()).thenReturn(attacker.player);
var cancelled = new org.bukkit.event.entity.EntityDamageByEntityEvent(attacker.player, target.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 4);
cancelled.setCancelled(true); fixture.dispatcher.accept(cancelled);
assertTrue(cancelled.isCancelled());
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.count() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var results = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.map(entry -> entry.getAsJsonObject("data")).toList();
assertEquals(2, results.size());
assertTrue(results.stream().anyMatch(value -> value.get("reason").getAsString().equals("mixed_mode")
&& value.get("outcome").getAsString().equals("refused")));
assertTrue(results.stream().anyMatch(value -> value.get("reason").getAsString().equals("prior_cancelled")
&& value.get("outcome").getAsString().equals("cancelled")
&& value.get("prior_cancelled").getAsBoolean()));
}
}
@Test void stalePotionAndLingeringSplashCancellationHaveBoundedNativeResolutions() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var potion = PartyCombatNativeTest.entity(org.bukkit.entity.ThrownPotion.class);
org.mockito.Mockito.when(potion.getShooter()).thenReturn(attacker.player);
fixture.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(potion));
PartyCombatNativeTest.exit(fixture, attacker);
var splash = new org.bukkit.event.entity.PotionSplashEvent(potion,
new java.util.HashMap<>(java.util.Map.of(target.player, 1.0)));
fixture.dispatcher.accept(splash); assertTrue(splash.isCancelled());
var cloud = PartyCombatNativeTest.entity(org.bukkit.entity.AreaEffectCloud.class);
var lingering = new org.bukkit.event.entity.LingeringPotionSplashEvent(potion, cloud);
fixture.dispatcher.accept(lingering); assertTrue(lingering.isCancelled());
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& (entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_splash_protection")
|| entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_lingering_protection")))
.count() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var decisions = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& (entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_splash_protection")
|| entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_lingering_protection")))
.map(entry -> entry.getAsJsonObject("data")).toList();
assertEquals(java.util.Set.of("party_splash_protection", "party_lingering_protection"), decisions.stream()
.map(value -> value.get("action_kind").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertTrue(decisions.stream().allMatch(value -> value.get("reason").getAsString().equals("stale_source")
&& value.get("outcome").getAsString().equals("refused")));
assertEquals("not_single_proved_player", decisions.stream().filter(value -> value.get("action_kind")
.getAsString().equals("party_lingering_protection")).findFirst().orElseThrow()
.get("target_status").getAsString());
}
}
@Test void protectedImpactPotionAndIgnitionDecisionsWithoutDamageEventsAreAudited() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(attacker.core.id).orElseThrow();
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
var arrow = PartyCombatNativeTest.projectile(fixture, attacker.player);
var impact = new org.bukkit.event.entity.ProjectileHitEvent(arrow, target.player);
fixture.dispatcher.accept(impact); assertTrue(impact.isCancelled());
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
var poison = new org.bukkit.event.entity.EntityPotionEffectEvent(target.player, null, effect,
attacker.player, org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, true);
fixture.dispatcher.accept(poison); assertTrue(poison.isCancelled());
var ignition = new org.bukkit.event.entity.EntityCombustByEntityEvent(arrow, target.player, 10.0f);
fixture.dispatcher.accept(ignition); assertTrue(ignition.isCancelled());
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().startsWith("party_"))
.count() < 3; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var outcomes = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().startsWith("party_"))
.map(entry -> entry.getAsJsonObject("data")).toList();
assertEquals(java.util.Set.of("party_projectile_impact_protection", "party_effect_application_protection",
"party_ignite_protection"), outcomes.stream().map(value -> value.get("action_kind").getAsString())
.collect(java.util.stream.Collectors.toSet()));
assertTrue(outcomes.stream().allMatch(value -> value.get("reason").getAsString().equals("same_party")
&& value.get("final_cancelled").getAsBoolean()));
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("combat.damage")));
}
}
@Test void serializedOldProjectileAfterRestartRetainsOldSessionAndNeverClaimsNewHolder() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
var originalSession = sessions.active(attacker.core.id).orElseThrow().session();
var arrow = PartyCombatNativeTest.projectile(fixture, attacker.player);
var bytes = arrow.getPersistentDataContainer().serializeToBytes();
fixture.plugin.setEnabled(false);
fixture.awaitWorkers();
var serialized = PartyCombatNativeTest.entity(org.bukkit.entity.Arrow.class);
org.mockito.Mockito.when(serialized.getShooter()).thenReturn(attacker.player);
serialized.getPersistentDataContainer().readFromBytes(bytes, true);
fixture.plugin.setEnabled(true);
assertTrue(PartyCombatNativeTest.hit(fixture, serialized, target.player).isCancelled());
for (int i = 0; i < 250 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("stale_source")); i++)
Thread.sleep(20);
fixture.plugin.setEnabled(false);
var result = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("stale_source"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(originalSession.toString(), result.get("source_session_id").getAsString());
assertEquals("unavailable", result.get("provenance_age_status").getAsString());
assertTrue(result.has("target_location"));
}
}
@Test void malformedAndForgedProjectileOriginsNeverBorrowCurrentCharacterOrParty() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var arrow = PartyCombatNativeTest.projectile(fixture, attacker.player);
var key = arrow.getPersistentDataContainer().getKeys().stream()
.filter(value -> value.getKey().equals("combat_origin_v1")).findFirst().orElseThrow();
arrow.getPersistentDataContainer().set(key, org.bukkit.persistence.PersistentDataType.STRING, "malformed");
assertTrue(PartyCombatNativeTest.hit(fixture, arrow, target.player).isCancelled());
arrow.getPersistentDataContainer().set(key, org.bukkit.persistence.PersistentDataType.STRING,
attacker.core.id + "/" + java.util.UUID.randomUUID() + "/" + attacker.core.record.id());
assertTrue(PartyCombatNativeTest.hit(fixture, arrow, target.player).isCancelled());
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.count() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var decisions = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.map(entry -> entry.getAsJsonObject("data")).toList();
assertEquals(List.of("stale_source", "stale_source"), decisions.stream()
.map(value -> value.get("reason").getAsString()).toList());
assertEquals("ordinary_or_unproved", decisions.getFirst().get("provenance_quality").getAsString());
assertFalse(decisions.getFirst().has("source_session_id"));
assertEquals("claimed_session_unverified", decisions.get(1).get("provenance_quality").getAsString());
assertFalse(decisions.stream().anyMatch(value -> value.has("source_party_id")));
}
}
@Test void attributedProjectileRechecksCurrentPartyAndExpiresWithOriginalSession() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var projectile = PartyCombatNativeTest.projectile(fixture, attacker.player);
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(attacker.core.id).orElseThrow();
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
assertTrue(PartyCombatNativeTest.hit(fixture, projectile, target.player).isCancelled());
PartyCombatNativeTest.exit(fixture, attacker);
assertTrue(PartyCombatNativeTest.hit(fixture, projectile, target.player).isCancelled());
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.count() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var reasons = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.map(entry -> entry.getAsJsonObject("data").get("reason").getAsString()).toList();
assertEquals(List.of("same_party", "stale_source"), reasons);
}
}
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 List<JsonObject> records() throws Exception {
var result = new ArrayList<JsonObject>();
var directory = data.resolve("events");
if (!Files.exists(directory)) return result;
try (var paths = Files.walk(directory)) {
for (var path : paths.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;
}
}
@@ -268,6 +268,7 @@ class PlayerSheetDropsNativeTest {
static final class WorldFixture {
final UUID playerId = UUID.randomUUID();
final World world;
final UUID worldId = UUID.randomUUID();
final Player player;
final Item item;
ItemStack stack;
@@ -307,6 +308,8 @@ class PlayerSheetDropsNativeTest {
default -> throw new AssertionError("Unexpected item call " + name);
});
world = proxy(World.class, (name, args) -> switch (name) {
case "getUID" -> worldId;
case "getName" -> "craft-world";
case "createEntity" -> {
creates++;
assertEquals(Item.class, args[1]);
@@ -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);
@@ -48,7 +48,17 @@ class RecipeDiscoveryNativeTest {
f.plugin.setEnabled(true);
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), online.recipeAwards);
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
f.plugin.setEnabled(false);
}
var discoveries = events(data).stream().filter(e -> e.get("type").getAsString().endsWith("recipe.discovered"))
.toList();
assertEquals(2, discoveries.size(), "One native first discovery per account; reload/join creates no duplicate");
assertEquals(java.util.Set.of("enable_online", "join"), discoveries.stream()
.map(e -> e.getAsJsonObject("data").get("trigger").getAsString())
.collect(java.util.stream.Collectors.toSet()));
assertTrue(discoveries.stream().allMatch(e -> e.getAsJsonObject("data").get("recipe_id")
.getAsString().equals("fantasystick:character_sheet")));
assertEquals(2, discoveries.stream().map(e -> e.get("id").getAsString()).distinct().count());
}
@Test void playerFailureIsLoggedIsolatedAndRetryableButRegistrationFailureAwardsNobody() throws Exception {
@@ -80,6 +90,13 @@ class RecipeDiscoveryNativeTest {
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), failing.knownRecipes);
} finally { f.plugin.getLogger().removeHandler(handler); }
}
var first = events(data.resolve("player-failure"));
assertEquals(1, first.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("action_kind")
&& e.getAsJsonObject("data").get("action_kind").getAsString().equals("recipe_discovery")
&& e.getAsJsonObject("data").get("reason").getAsString().equals("native_failed")).count());
assertEquals(2, first.stream().filter(e -> e.get("type").getAsString().endsWith("recipe.discovered")).count(),
"Healthy and retried players each get one first-discovery acknowledgment");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("registration-failure"))) {
var online = new FantasyStickLifecycleNativeTest.Actor(data.resolve("registration-failure"));
f.players.put(online.core.id, online.player); f.failRegistrationAt = "recipe";
@@ -88,6 +105,68 @@ class RecipeDiscoveryNativeTest {
assertTrue(online.recipeAwards.isEmpty());
assertTrue(f.recipes.isEmpty());
}
var registration = data.resolve("registration-failure").resolve("events");
if (java.nio.file.Files.isDirectory(registration))
assertEquals(0, events(data.resolve("registration-failure")).stream()
.filter(e -> e.get("type").getAsString().endsWith("recipe.discovered")).count());
}
@Test void nativeDiscoveryWithoutPostconditionReportsUnavailableAndRetriesWithoutInventingSuccess() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data);
var unavailable = new AtomicBoolean(true);
doAnswer(call -> {
if (unavailable.getAndSet(false)) return 0;
var requested = ((java.util.Collection<?>) call.getArgument(0)).stream()
.map(org.bukkit.NamespacedKey.class::cast).toList();
player.recipeAwards.add(requested); player.knownRecipes.addAll(requested); return requested.size();
}).when(player.player).discoverRecipes(anyCollection());
f.players.put(player.core.id, player.player);
f.dispatcher.accept(new PlayerJoinEvent(player.player, "unavailable"));
assertTrue(player.knownRecipes.isEmpty());
f.dispatcher.accept(new PlayerJoinEvent(player.player, "retry"));
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), player.knownRecipes);
f.plugin.setEnabled(false);
var records = events(data);
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("recipe.discovered")
&& e.getAsJsonObject("data").get("player_id").getAsString().equals(player.core.id.toString())).count());
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("reason")
&& e.getAsJsonObject("data").get("reason").getAsString().equals("native_unavailable")).count());
}
}
@Test void acknowledgedNativeDiscoveryRetainsStableIdentityThroughEventSinkOutage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
var joining = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(joining.core.id, joining.player);
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "join"));
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), joining.knownRecipes);
f.plugin.setEnabled(false);
f.plugin.setEnabled(true);
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "already known"));
f.plugin.setEnabled(false);
var discoveries = events(data).stream().filter(e -> e.get("type").getAsString().endsWith("recipe.discovered")
&& e.getAsJsonObject("data").get("player_id").getAsString().equals(joining.core.id.toString())).toList();
assertEquals(1, discoveries.stream().map(e -> e.get("id").getAsString()).distinct().count(),
"An acknowledged first discovery must be recoverable with its original event identity");
assertEquals("join", discoveries.getFirst().getAsJsonObject("data").get("trigger").getAsString());
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
}
}
private static List<com.google.gson.JsonObject> events(Path directory) throws Exception {
var entries = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(directory.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) entries.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return entries;
}
private static long records(Path directory) throws Exception {
@@ -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);
@@ -0,0 +1,536 @@
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 java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered admin/lifecycle outcome -> local JSONL, not isolated bridge invocation. */
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);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"inspect", actor.core.id.toString()});
fixture.tick();
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("pending session=")));
awaitType(fixture, "recovery.inspected");
fixture.plugin.setEnabled(false);
var entry = records().stream().filter(value -> value.get("type").getAsString()
.endsWith("recovery.inspected")).findFirst().orElseThrow();
var fields = entry.getAsJsonObject("data");
assertEquals(actor.core.id.toString(), fields.get("target_player_id").getAsString());
assertEquals("pending", fields.get("receipt_phase").getAsString());
assertEquals("authorized", fields.get("outcome").getAsString());
assertFalse(fields.toString().contains("DIAMOND_SWORD"));
assertFalse(fields.toString().contains("item_meta"));
}
}
@Test void failedAdventureSaveRetainsEffectivePolicyAndEmitsNoChange() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var collision = data.resolve("config.yml");
Files.createDirectory(collision);
Files.writeString(collision.resolve("block"), "not a config");
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"config", "adventure-mode", "false"});
fixture.tick();
assertTrue(administrator.messages.getLast().contains("save failed"));
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode"});
assertTrue(administrator.messages.getLast().contains("adventure-mode=true"));
for (int i = 0; i < 250 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("save_failed")); i++)
Thread.sleep(20);
fixture.plugin.setEnabled(false);
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString().endsWith("config.changed")));
Files.delete(collision.resolve("block"));
Files.delete(collision);
}
}
@Test void acknowledgedAdventureChangeKeepsStableEventIdAcrossWriterOutageAndRestart() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"config", "adventure-mode", "false"});
fixture.tick();
fixture.plugin.setEnabled(false);
fixture.awaitWorkers();
java.util.Set<String> pending;
try (var paths = Files.list(data.resolve("session-recovery-event-intents"))) {
pending = paths.filter(path -> path.getFileName().toString().endsWith(".pending"))
.map(path -> path.getFileName().toString().replace(".pending", ""))
.collect(java.util.stream.Collectors.toSet());
}
assertEquals(1, pending.size());
fixture.plugin.setEnabled(true);
awaitType(fixture, "config.changed");
fixture.plugin.setEnabled(false);
var saved = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("config.changed")).toList();
assertEquals(pending, saved.stream().map(entry -> entry.get("id").getAsString())
.collect(java.util.stream.Collectors.toSet()));
}
}
@Test void explicitRecoveryWithoutReceiptIsRefusedWithoutInventingSession() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
actor.core.inventory.setItemInOffHand(null);
fixture.players.put(actor.core.id, actor.player);
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"recover", actor.core.id.toString()});
fixture.tick();
awaitType(fixture, "recovery.resolved");
fixture.plugin.setEnabled(false);
var result = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("refused", result.get("outcome").getAsString());
assertEquals("no_receipt", result.get("reason").getAsString());
assertFalse(result.has("character_id"));
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")));
}
}
@Test void failedRecoveryReceiptCompletionRemainsAmbiguousWithoutSuccessEvent() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
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));
awaitRefusedExitReady(fixture, sessions, actor);
actor.core.inventory.setItem(0, null);
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"recover", actor.core.id.toString()});
fixture.receipts().find(actor.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
var receipt = data.resolve("sessions").resolve(actor.core.id + ".yml");
var backup = data.resolve("receipt-backup.yml");
Files.move(receipt, backup);
Files.createDirectory(receipt);
Files.writeString(receipt.resolve("block"), "not a receipt");
fixture.tick();
awaitType(fixture, "recovery.resolved");
var result = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("ambiguous", result.get("outcome").getAsString());
assertEquals("receipt_save_failed", result.get("reason").getAsString());
assertEquals("restored", result.get("current_phase").getAsString());
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")));
Files.delete(receipt.resolve("block"));
Files.delete(receipt);
Files.move(backup, receipt);
fixture.plugin.setEnabled(false);
}
}
@Test void unreadableReceiptRefusesRegisteredRecoveryWithoutTouchingNativeItems() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
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));
awaitRefusedExitReady(fixture, sessions, actor);
var nativeBefore = actor.core.snapshot();
var receipt = data.resolve("sessions").resolve(actor.core.id + ".yml");
var backup = data.resolve("saved-receipt.yml");
Files.move(receipt, backup);
Files.createDirectory(receipt);
Files.writeString(receipt.resolve("block"), "not a receipt");
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"recover", actor.core.id.toString()});
fixture.tick();
awaitType(fixture, "recovery.resolved");
assertArrayEquals(nativeBefore, actor.core.snapshot());
var result = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("failed", result.get("outcome").getAsString());
assertEquals("receipt_read_failed", result.get("reason").getAsString());
assertFalse(result.has("session_id"));
Files.delete(receipt.resolve("block"));
Files.delete(receipt);
Files.move(backup, receipt);
fixture.plugin.setEnabled(false);
}
}
@Test void ambiguousCopiedBoxThroughRegisteredRecoveryRetainsReceiptAndItems() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
actor.core.cursor = actor.core.inventory.getItem(9).clone();
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.AMBIGUOUS,
sessions.exit(actor.player));
fixture.awaitCharacterAndReceipt(actor);
awaitRefusedExitReady(fixture, sessions, actor);
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"recover", actor.core.id.toString()});
fixture.tick();
awaitType(fixture, "recovery.resolved");
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
fixture.plugin.setEnabled(false);
var result = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("retained", result.get("outcome").getAsString());
assertEquals("ambiguous", result.get("reason").getAsString());
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")));
}
}
@Test void uncertainNativeRecoveryReportsAmbiguousWithoutInventingEquipmentSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
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);
awaitRefusedExitReady(fixture, sessions, actor);
actor.core.inventory.setItem(0, null);
actor.core.cursor = actor.core.inventory.getItem(9).clone();
actor.core.inventory.setItem(9, null);
actor.core.beforeCursorMutation = () -> { throw new IllegalStateException("Uncertain cursor write"); };
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"recover", actor.core.id.toString()});
fixture.tick();
fixture.awaitCharacterAndReceipt(actor);
awaitType(fixture, "recovery.resolved");
actor.core.beforeCursorMutation = () -> {};
fixture.plugin.setEnabled(false);
var results = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).toList();
assertEquals(1, results.size());
assertEquals("ambiguous", results.getFirst().getAsJsonObject("data").get("outcome").getAsString());
assertEquals("native_exception", results.getFirst().getAsJsonObject("data").get("reason").getAsString());
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")));
}
}
@Test void acknowledgedCommandRecoveryReplaysStableResultAfterClosedWriter() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
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);
awaitRefusedExitReady(fixture, sessions, actor);
actor.core.inventory.setItem(0, null);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"recover", actor.core.id.toString()});
fixture.tick();
fixture.awaitCharacterAndReceipt(actor);
fixture.plugin.setEnabled(false);
fixture.awaitWorkers();
java.util.Set<String> pending;
try (var paths = Files.list(data.resolve("session-recovery-event-intents"))) {
pending = paths.filter(path -> path.getFileName().toString().endsWith(".pending"))
.map(path -> path.getFileName().toString().replace(".pending", ""))
.collect(java.util.stream.Collectors.toSet());
}
assertEquals(1, pending.size());
fixture.plugin.setEnabled(true);
awaitType(fixture, "recovery.resolved");
fixture.plugin.setEnabled(false);
var outcome = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).toList();
assertEquals(pending, outcome.stream().map(entry -> entry.get("id").getAsString())
.collect(java.util.stream.Collectors.toSet()));
assertEquals(1, outcome.size());
}
}
@Test void restartedAutomaticReconciliationRetainsUnverifiedHealthAndOriginalLocationStatus() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
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);
fixture.tick();
fixture.awaitCharacterAndReceipt(actor);
awaitType(fixture, "recovery.resolved");
fixture.plugin.setEnabled(false);
var results = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).toList();
assertEquals(1, results.size());
var value = results.getFirst().getAsJsonObject("data");
assertEquals("ambiguous", value.get("outcome").getAsString());
assertEquals("health_unverified", value.get("reason").getAsString());
assertEquals("packed", value.get("prior_phase").getAsString());
assertEquals("restored", value.get("current_phase").getAsString());
assertEquals("captured_current_only", value.get("location_status").getAsString());
assertEquals("unavailable", value.get("original_location_status").getAsString());
assertEquals("captured_at_reconciliation", value.get("current_location_status").getAsString());
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")));
}
}
@Test void registeredProofBasedRecoveryCorrelatesEquipmentWithoutAnotherSessionExit() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
var session = sessions.active(actor.core.id).orElseThrow().session();
var originalAttempt = ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(
sessions, "owned")).get(actor.core.id);
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);
awaitRefusedExitReady(fixture, sessions, actor);
actor.core.inventory.setItem(0, null);
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"recover", actor.core.id.toString()});
fixture.tick();
fixture.awaitCharacterAndReceipt(actor);
assertNotNull(FantasyStickLifecycleNativeTest.Fixture.field(originalAttempt, "sessionRecoveryAction"));
assertTrue((boolean) FantasyStickLifecycleNativeTest.Fixture.field(originalAttempt, "sessionRecoveryResolved"),
"Completed receipt should trigger one session recovery outcome");
awaitType(fixture, "recovery.resolved");
fixture.plugin.setEnabled(false);
var entries = records();
var recovered = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).toList();
assertEquals(1, recovered.size());
var value = recovered.getFirst().getAsJsonObject("data");
assertEquals("restored", value.get("outcome").getAsString());
assertEquals(session.toString(), value.get("session_id").getAsString());
assertEquals("packed", value.get("prior_phase").getAsString());
assertTrue(value.has("equipment_recovery_operation_id"));
assertEquals("captured_at_entry", value.get("original_location_status").getAsString());
assertEquals("captured_at_reconciliation", value.get("current_location_status").getAsString());
assertTrue(value.has("original_x"));
assertTrue(value.has("current_x"));
assertEquals(1, entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.exited")).count());
}
}
@Test void registeredAdventureReadAndAcknowledgedChangeHaveSafeRevisionAndNoInvalidChange() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode"});
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode", "false"});
fixture.tick();
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode", "wrong"});
awaitType(fixture, "config.changed");
fixture.plugin.setEnabled(false);
var entries = records();
var read = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("config.read") && entry.getAsJsonObject("data").has("key")
&& entry.getAsJsonObject("data").get("key").getAsString().equals("adventure-mode")).toList();
var changed = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("config.changed") && entry.getAsJsonObject("data").has("key")
&& entry.getAsJsonObject("data").get("key").getAsString().equals("adventure-mode")).toList();
assertEquals(1, read.size());
assertEquals("true", read.getFirst().getAsJsonObject("data").get("effective_value").getAsString());
assertEquals(1, changed.size(), "Invalid input must not produce an acknowledged config change");
var fields = changed.getFirst().getAsJsonObject("data");
assertEquals("true", fields.get("before_value").getAsString());
assertEquals("false", fields.get("after_value").getAsString());
assertTrue(fields.get("persistence_revision").getAsString().matches("sha256:[0-9a-f]{64}"));
}
}
@Test void inspectionRevokedBeforeReadResponseDoesNotPublishProtectedTarget() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"inspect", actor.core.id.toString()});
administrator.allowed = false;
fixture.tick();
assertTrue(administrator.messages.isEmpty());
for (int i = 0; i < 200 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("permission_changed")); i++)
Thread.sleep(20);
fixture.plugin.setEnabled(false);
var entries = records();
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("recovery.inspected")));
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
.map(entry -> entry.getAsJsonObject("data"))
.anyMatch(value -> value.has("reason") && value.get("reason").getAsString()
.equals("permission_changed") && !value.has("target_player_id")));
}
}
@Test void authorizedOfflineInspectionReportsOnlySavedReceiptAndUnknownNativeLocation() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var administrator = new EquipmentAdminCommandNativeTest.Sender();
var target = java.util.UUID.randomUUID();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"inspect", target.toString()});
fixture.tick();
awaitType(fixture, "recovery.inspected");
fixture.plugin.setEnabled(false);
var result = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.inspected")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(target.toString(), result.get("target_player_id").getAsString());
assertEquals("absent", result.get("receipt_phase").getAsString());
assertEquals("offline_unavailable", result.get("native_phase").getAsString());
assertEquals("receipt_only", result.get("custody_quality").getAsString());
assertEquals("not_applicable", result.get("location_status").getAsString());
assertFalse(result.has("session_id"));
}
}
@Test void registeredPermissionGateCannotExposeProtectedInspectionOrTarget() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
var denied = new EquipmentAdminCommandNativeTest.Sender();
denied.allowed = false;
fixture.adminCommand.execute(denied.port, "fstickadmin",
new String[]{"inspect", actor.core.id.toString()});
assertTrue(denied.messages.getLast().toLowerCase(java.util.Locale.ROOT).contains("permission"));
fixture.plugin.setEnabled(false);
var entries = records();
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("recovery.inspected")));
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
.map(entry -> entry.getAsJsonObject("data"))
.noneMatch(fields -> fields.has("action_kind") && fields.get("action_kind").getAsString()
.equals("equipment_inspect") && fields.has("target_player_id")));
}
}
private void awaitRefusedExitReady(FantasyStickLifecycleNativeTest.Fixture fixture,
games.dmg.fantasystick.session.EquipmentSessions sessions,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
var owned = (java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "owned");
var pending = owned.get(actor.core.id);
assertNotNull(pending);
for (int i = 0; i < 8; i++) {
fixture.awaitCharacterAndReceipt(actor);
if (!(boolean) FantasyStickLifecycleNativeTest.Fixture.field(pending, "saving")) return;
fixture.tick();
}
fail("Refused exit persistence callback did not settle before recovery request");
}
private void awaitType(FantasyStickLifecycleNativeTest.Fixture fixture, String type) throws Exception {
for (int i = 0; i < 300; i++) {
var callback = fixture.tasks.poll(20, TimeUnit.MILLISECONDS);
if (callback != null) callback.run();
if (records().stream().anyMatch(entry -> entry.get("type").getAsString().endsWith(type))) return;
}
fail("Registered recovery outcome did not reach JSONL: " + type + "; observed types="
+ records().stream().map(entry -> entry.get("type").getAsString()).toList());
}
private List<JsonObject> records() throws Exception {
var result = new ArrayList<JsonObject>();
Path directory = data.resolve("events");
if (!Files.exists(directory)) return result;
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,178 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
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);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
actor.core.location.add(3, 0, 1);
double exitX = actor.core.location.getX();
sessions.exit(actor.player);
fixture.awaitCharacterAndReceipt(actor);
actor.core.location.add(8, 0, 0);
double attemptX = actor.core.location.getX();
var saved = fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow();
var exit = saved.lastExit();
assertNotNull(exit);
var another = new FantasyStickLifecycleNativeTest.Actor(data);
actor.core.inventory.setItemInOffHand(another.core.inventory.getItemInOffHand().clone());
var before = actor.core.snapshot();
fixture.dispatcher.accept(new PlayerJoinEvent(actor.player, (net.kyori.adventure.text.Component) null));
fixture.tick(); fixture.tick();
assertArrayEquals(before, actor.core.snapshot(), "A refused switch cannot alter equipment");
assertTrue(actor.messages.getLast().contains("same character may be resumed now"));
actor.core.inventory.setItemInOffHand(new CharacterSheets().create(actor.core.record));
fixture.dispatcher.accept(new PlayerJoinEvent(actor.player, (net.kyori.adventure.text.Component) null));
fixture.tick(); fixture.tick(); fixture.tick();
assertTrue(actor.core.marker().endsWith("|PACKED"));
fixture.plugin.setEnabled(false);
var events = records();
var started = events.stream().filter(e -> e.get("type").getAsString().endsWith("character.switch_delay_started"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(actor.core.id.toString(), started.get("player_id").getAsString());
assertEquals(exit.characterId().toString(), started.get("character_id").getAsString());
assertEquals(exit.deadline().toString(), started.get("deadline").getAsString());
assertEquals("900", started.get("duration_seconds").getAsString());
assertEquals(saved.revision().toString(), started.get("revision_after").getAsString());
assertEquals(exitX, started.getAsJsonObject("location").get("x").getAsDouble());
assertEquals(exit.exitedAt().toString(), events.stream()
.filter(e -> e.get("type").getAsString().endsWith("character.switch_delay_started"))
.findFirst().orElseThrow().get("time").getAsString());
var decisions = events.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.has("action_kind") && d.get("action_kind").getAsString().equals("character_switch_preflight"))
.toList();
assertEquals(3, decisions.size(), "Initial entry, refused switch and same-character resumption are distinct preflights");
assertTrue(decisions.stream().anyMatch(d -> d.get("outcome").getAsString().equals("allowed")
&& d.get("last_exited_character_id").getAsString().equals("none")));
assertTrue(decisions.stream().anyMatch(d -> d.get("outcome").getAsString().equals("refused")
&& d.get("reason").getAsString().equals("cooldown")
&& d.getAsJsonObject("location").get("x").getAsDouble() == attemptX));
var observations = events.stream().filter(e -> e.get("type").getAsString().endsWith("action.observed"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.has("action_kind") && d.get("action_kind").getAsString().equals("character_switch_preflight"))
.toList();
assertEquals(decisions.stream().map(d -> d.get("action_id").getAsString()).collect(java.util.stream.Collectors.toSet()),
observations.stream().map(d -> d.get("action_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertTrue(decisions.stream().anyMatch(d -> d.get("outcome").getAsString().equals("allowed")
&& d.get("same_character").getAsBoolean()));
}
}
@Test void acknowledgedExitRecoversSameDeadlineEventAfterWriterOutage() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
sessions.exit(actor.player);
fixture.awaitCharacterAndReceipt(actor);
var exit = fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int i = 0; i < 80 && records().stream()
.noneMatch(e -> e.get("type").getAsString().endsWith("character.switch_delay_started")); i++)
Thread.sleep(10);
fixture.plugin.setEnabled(false);
var recovered = records().stream().filter(e -> e.get("type").getAsString()
.endsWith("character.switch_delay_started")).toList();
assertEquals(1, recovered.stream().map(e -> e.get("id").getAsString()).distinct().count());
assertEquals(exit.deadline().toString(), recovered.getFirst().getAsJsonObject("data")
.get("deadline").getAsString());
assertEquals(actor.core.record.id().toString(), recovered.getFirst().getAsJsonObject("data")
.get("character_id").getAsString());
}
}
@Test void twoPersistedExitsRemainDistinctDuringWriterOutageAndRestart() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
sessions.exit(actor.player);
fixture.awaitCharacterAndReceipt(actor);
var first = fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
fixture.reenter(actor);
sessions.exit(actor.player);
fixture.awaitCharacterAndReceipt(actor);
var second = fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
assertNotEquals(first.sessionId(), second.sessionId());
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int i = 0; i < 80 && records().stream().filter(e -> e.get("type").getAsString()
.endsWith("character.switch_delay_started")).map(e -> e.get("id").getAsString()).distinct().count() < 2; i++)
Thread.sleep(10);
fixture.plugin.setEnabled(false);
var starts = records().stream().filter(e -> e.get("type").getAsString()
.endsWith("character.switch_delay_started"))
.collect(java.util.stream.Collectors.toMap(e -> e.get("id").getAsString(), e -> e,
(one, duplicate) -> one, java.util.LinkedHashMap::new));
assertEquals(2, starts.size());
assertEquals(java.util.Set.of(first.sessionId().toString(), second.sessionId().toString()),
starts.values().stream().map(e -> e.getAsJsonObject("data").get("session_id").getAsString())
.collect(java.util.stream.Collectors.toSet()));
}
}
private List<com.google.gson.JsonObject> records() throws Exception {
var events = new ArrayList<com.google.gson.JsonObject>();
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;
}
}