Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ac4867821 | ||
|
|
b0996b0c6e | ||
|
|
850a04c4a0 | ||
|
|
439c1b9cdd | ||
|
|
2ae8f1ddf9 | ||
|
|
912c077da1 | ||
|
|
9b378b4237 | ||
|
|
93897ce516 | ||
|
|
fc49ee1cfb | ||
|
|
ee7d9036a2 | ||
|
|
4616ee80e3 | ||
|
|
e6b66d9e34 | ||
|
|
fc90af3a62 | ||
|
|
f6d95ca338 | ||
|
|
db4c3b2842 | ||
|
|
830049b339 | ||
|
|
fcbc2814c2 | ||
|
|
897a0606ba | ||
|
|
c13c3093cf | ||
|
|
bb8d1857b4 | ||
|
|
c7fce1a53d | ||
|
|
af7c75b6ed | ||
|
|
39881ad6fc | ||
|
|
9737ed0ddb | ||
|
|
53366b3c50 | ||
|
|
84205c0c14 | ||
|
|
2cc053e6ff | ||
|
|
3f104cb0e2 | ||
|
|
13828054e5 | ||
|
|
39c58ef4d9 | ||
|
|
ec73018f93 | ||
|
|
b86f703d14 | ||
|
|
2231dd058a | ||
|
|
ff28f2e98d | ||
|
|
21877f6820 | ||
|
|
228de3d08f | ||
|
|
e3bc854481 | ||
|
|
3163a491a0 | ||
|
|
08d13d2192 | ||
|
|
1d0cdc8f0f | ||
|
|
9674a8e5e6 | ||
|
|
dd75e6a76f | ||
|
|
22d92b5bbf | ||
|
|
26ccc95cdd | ||
|
|
8ae8cdb603 | ||
|
|
d18a41af6c | ||
|
|
6428ab999b | ||
|
|
6bb58cd52d | ||
|
|
25eb6e3a6b | ||
|
|
51b055da12 | ||
|
|
60ccb34ab6 |
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;
|
||||
@@ -163,12 +164,43 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
|
||||
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
|
||||
private games.dmg.fantasystick.events.LocalEventLog eventLog;
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge preferenceEvents;
|
||||
private games.dmg.fantasystick.events.EventCaptureListener eventCapture;
|
||||
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
|
||||
private games.dmg.fantasystick.events.RareEventBridge rareEvents;
|
||||
private games.dmg.fantasystick.events.BookEventBridge bookEvents;
|
||||
private games.dmg.fantasystick.events.ProgressionEventBridge progressionEvents;
|
||||
private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents;
|
||||
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
|
||||
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
|
||||
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
|
||||
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> adminRevivalAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.PartyEventBridge partyAuditEvents;
|
||||
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
|
||||
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
|
||||
private games.dmg.fantasystick.events.SessionEventBridge sessionAuditEvents;
|
||||
private games.dmg.fantasystick.events.NutritionEventBridge nutritionAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> nutritionAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> switchAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.SwitchConfigEventBridge switchConfigEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> switchConfigAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> identityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentAuditEvents;
|
||||
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();
|
||||
@@ -234,7 +266,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var rewardRules = combatRewardRules(hardMobXp);
|
||||
var fireballConfiguration = fireballConfiguration();
|
||||
var fireballInitial = games.dmg.fantasystick.persistence.FireballSettings.parse(fireballConfiguration);
|
||||
var greatSwordSettings = greatSwordSettings();
|
||||
var greatSwordConfiguration = greatSwordConfiguration();
|
||||
var greatSwordInitial = games.dmg.fantasystick.persistence.GreatSwordSettings.parse(greatSwordConfiguration);
|
||||
var frostShardConfiguration = frostShardConfiguration();
|
||||
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
|
||||
var longbowConfiguration = longbowConfiguration();
|
||||
@@ -385,7 +418,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
net.kyori.adventure.text.Component.text(message), net.kyori.adventure.text.Component.empty()));
|
||||
}
|
||||
}));
|
||||
sessions.attachPartyDefeat(characterParties::defeat);
|
||||
sessions.attachPartyDefeat(character->{var before=characterParties.partyOf(character).orElse(null);return characterParties.defeat(character).thenApply(outcome->{var bridge=partyAuditEvents;if(bridge!=null)bridge.finalDefeat(character,before,outcome);return outcome;});});
|
||||
var reconcilingParties = characterParties;
|
||||
var partyCharacters = reconcilingParties.all().stream().flatMap(party -> java.util.stream.Stream.concat(
|
||||
party.members().stream(), party.invitations().stream().map(games.dmg.fantasystick.party.CharacterParty.Invitation::character))).distinct().toList();
|
||||
@@ -422,7 +455,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
secondWindSettings = new games.dmg.fantasystick.persistence.SecondWindSettings(
|
||||
getDataFolder().toPath(), secondWindConfiguration);
|
||||
secondWind = new games.dmg.fantasystick.ability.SecondWind(secondWindInitial, characters, sessions, nextTick,
|
||||
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read);
|
||||
this::dispatchOwnedHealing, games.dmg.fantasystick.session.NativeHealthLimit::read);
|
||||
stoneSkinSettings = new games.dmg.fantasystick.persistence.StoneSkinSettings(
|
||||
getDataFolder().toPath(), stoneSkinConfiguration);
|
||||
combatMitigation = new games.dmg.fantasystick.ability.CombatMitigation();
|
||||
@@ -443,7 +476,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
mendingLightSettings = new games.dmg.fantasystick.persistence.MendingLightSettings(
|
||||
getDataFolder().toPath(), mendingLightConfiguration);
|
||||
mendingLight = new games.dmg.fantasystick.ability.MendingLight(mendingLightInitial, characters, sessions, parties,
|
||||
nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
nextTick, this::dispatchOwnedHealing,
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
|
||||
purifySettings = new games.dmg.fantasystick.persistence.PurifySettings(getDataFolder().toPath(), purifyConfiguration);
|
||||
@@ -469,7 +502,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
sanctuarySettings = new games.dmg.fantasystick.persistence.SanctuarySettings(
|
||||
getDataFolder().toPath(), sanctuaryConfiguration);
|
||||
sanctuary = new games.dmg.fantasystick.ability.Sanctuary(sanctuaryInitial, characters, sessions, parties,
|
||||
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
combatMitigation, nextTick, this::dispatchOwnedHealing,
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()),
|
||||
particleDelivery);
|
||||
@@ -479,7 +512,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
warBannerInitial, sessions, parties, outgoingDamageBonus, particleDelivery);
|
||||
meteorSettings = new games.dmg.fantasystick.persistence.MeteorSettings(getDataFolder().toPath(), meteorConfiguration);
|
||||
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties, particleDelivery);
|
||||
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
|
||||
greatSwordSettings = new games.dmg.fantasystick.persistence.GreatSwordSettings(
|
||||
getDataFolder().toPath(), greatSwordConfiguration);
|
||||
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordInitial);
|
||||
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
|
||||
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
|
||||
(size, title) -> getServer().createInventory(null, size, title),
|
||||
@@ -509,11 +544,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var gearItems=new games.dmg.fantasystick.items.GearItems(this);sessions.attachGear(gearItems);
|
||||
vitals = new games.dmg.fantasystick.session.SessionVitals();
|
||||
sessions.attachVitals(vitals);
|
||||
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
|
||||
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions, nextTick);
|
||||
sessions.attachNutritionPolicy(nutritionPolicy);
|
||||
outOfCombatRecoverySettings = new games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings(
|
||||
getDataFolder().toPath(), recoveryConfiguration);
|
||||
outOfCombatRecovery = new games.dmg.fantasystick.session.OutOfCombatRecovery(recoveryInitial, characters, sessions,
|
||||
Clock.systemUTC(), nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
Clock.systemUTC(), nextTick, this::dispatchOwnedHealing,
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
problem -> getLogger().log(Level.WARNING,
|
||||
"Out-of-combat recovery failed for character " + problem.character(), problem.cause()), combatFeedback);
|
||||
@@ -526,7 +562,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
healthStealSettings = new games.dmg.fantasystick.persistence.HealthStealSettings(
|
||||
getDataFolder().toPath(), healthStealInitial.healing().toString());
|
||||
healthSteal = new games.dmg.fantasystick.passive.HealthSteal(healthStealInitial, characters, sessions, nextTick,
|
||||
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
this::dispatchOwnedHealing, games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()),
|
||||
combatFeedback);
|
||||
speedI = new games.dmg.fantasystick.passive.SpeedI(sessions);
|
||||
@@ -599,7 +635,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
rareShopMenus=new games.dmg.fantasystick.shop.RareShopMenus(this,rareShop);var localRareMenus=rareShopMenus;
|
||||
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;if(rareEvents!=null)rareEvents.catalogRefreshed(catalog);try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
|
||||
rareMerchantController=new games.dmg.fantasystick.shop.RareMerchantController(this,rareMerchants,rareShopMenus::open);
|
||||
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,event->getServer().getPluginManager().callEvent(event),nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
|
||||
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,this::dispatchOwnedHealing,nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
|
||||
rareReskills=new games.dmg.fantasystick.shop.RareReskillService(rareInventory,characters,sessions,nextTick,record->{for(String ability:record.learned())abilityEngine.remove(record.id(),ability);speedI.sweep();lowLightVision.sweep();},failure->getLogger().log(Level.WARNING,"Rare Reskill use failed",failure));
|
||||
rareMagicCores=new games.dmg.fantasystick.shop.RareMagicCoreService(rareInventory,characters,sessions,nextTick,failure->getLogger().log(Level.WARNING,"Rare Magic Core use failed",failure));
|
||||
rarePotions.install();rareReskills.install();rareMagicCores.install();books.attachRareInventory(rareInventory,rarePotions,rareReskills,rareMagicCores);
|
||||
@@ -641,6 +677,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
|
||||
inspireSettings, inspire, abilityRegistry);
|
||||
admin.attachFireball(fireballSettings, fireball);
|
||||
admin.attachGreatSword(greatSwordSettings, greatSword);
|
||||
admin.attachMendingLight(mendingLightSettings, mendingLight);
|
||||
admin.attachPurify(purifySettings, purify);
|
||||
admin.attachGuardianBond(guardianBondSettings, guardianBond);
|
||||
@@ -702,16 +739,104 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getVersion(), getServer().getMinecraftVersion(), getDescription().getVersion()), 1024, 134_217_728L,
|
||||
message -> getLogger().warning(message));
|
||||
eventCapture = new games.dmg.fantasystick.events.EventCaptureListener(eventLog, sessions, Clock.systemUTC());
|
||||
preferenceEvents = new games.dmg.fantasystick.events.PreferenceEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), sessions, feedbackPreferences,
|
||||
failure -> getLogger().log(Level.WARNING, "Preference event publication proof failed", failure));
|
||||
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);
|
||||
progressionEvents = new games.dmg.fantasystick.events.ProgressionEventBridge(eventLog::emit, Clock.systemUTC());books.attachProgressionEvents(progressionEvents);
|
||||
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");
|
||||
abilityEngine.enableCastEventsFor("rallying_cry");
|
||||
abilityEngine.enableCastEventsFor("frost_nova");
|
||||
abilityEngine.enableCastEventsFor("chain_lightning");
|
||||
abilityEngine.enableCastEventsFor("sanctuary");
|
||||
abilityEngine.enableCastEventsFor("war_banner");
|
||||
abilityEngine.enableCastEventsFor("meteor");
|
||||
admin.attachAbilityConfigEvents(new games.dmg.fantasystick.events.AbilityConfigEventBridge(eventLog::emit, Clock.systemUTC()));
|
||||
passiveEvents = new games.dmg.fantasystick.events.PassiveEventBridge(eventLog::emit, Clock.systemUTC());books.attachPassiveEvents(passiveEvents);admin.attachPassiveEvents(passiveEvents);
|
||||
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(eventLog::emit, Clock.systemUTC());outOfCombatRecovery.attachEvents(recoveryEvents,record->record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)?quickerRegeneration.bonus():java.math.BigInteger.ZERO);
|
||||
reviveEvents = new games.dmg.fantasystick.events.ReviveEventBridge(eventLog::emit, Clock.systemUTC());revive.attachEvents(reviveEvents);
|
||||
adminRevivalEvents = new games.dmg.fantasystick.events.AdminRevivalEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Admin revival event intent failed", failure));
|
||||
admin.attachAdminRevivalEvents(adminRevivalEvents);
|
||||
revive.attachAdminRevivalEvents(adminRevivalEvents);
|
||||
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
|
||||
partyCombat.attachProtectionEvents(new games.dmg.fantasystick.events.PartyProtectionEventBridge(
|
||||
eventLog::emit, Clock.systemUTC()));
|
||||
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC());combatExperience.attachEvents(combatRewardEvents);
|
||||
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC());characters.attachCoinEvents(coinEvents);admin.attachCoinEvents(coinEvents);
|
||||
sessionAuditEvents = new games.dmg.fantasystick.events.SessionEventBridge(eventLog::emit,Clock.systemUTC());sessions.attachSessionEvents(sessionAuditEvents);
|
||||
nutritionAuditEvents = new games.dmg.fantasystick.events.NutritionEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Nutrition event intent failed", failure));
|
||||
sessions.attachNutritionEvents(nutritionAuditEvents);
|
||||
nutritionPolicy.attachEvents(nutritionAuditEvents);
|
||||
switchAuditEvents = new games.dmg.fantasystick.events.SwitchDelayEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Switch deadline event intent failed", failure));
|
||||
sessions.attachSwitchEvents(switchAuditEvents);
|
||||
switchConfigEvents = new games.dmg.fantasystick.events.SwitchConfigEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Switch configuration event intent failed", failure));
|
||||
admin.attachSwitchConfigEvents(switchConfigEvents);
|
||||
var identitySessions = sessions;
|
||||
identityAuditEvents = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
playerId -> identitySessions.active(playerId).map(games.dmg.fantasystick.session.EquipmentSessions.Active::session),
|
||||
failure -> getLogger().log(Level.WARNING, "Character identity event intent failed", failure));
|
||||
crafting.attachIdentityEvents(identityAuditEvents);
|
||||
admin.attachCharacterIssueEvents(identityAuditEvents);
|
||||
anvilNames.attachIdentityEvents(identityAuditEvents);
|
||||
sessions.attachIdentityEvents(identityAuditEvents);
|
||||
equipmentAuditEvents = new games.dmg.fantasystick.events.EquipmentEventBridge(
|
||||
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);
|
||||
admin.attachDownedEvents(downedEvents);
|
||||
getServer().getPluginManager().registerEvents(eventCapture, this);
|
||||
messageAuditEvents = new games.dmg.fantasystick.events.MessageEventBridge(
|
||||
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;
|
||||
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; switchConfigEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
|
||||
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
|
||||
}
|
||||
getServer().getPluginManager().registerEvents(crafting, this);
|
||||
@@ -786,12 +911,63 @@ 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 replaySwitchConfigEvents = switchConfigEvents;
|
||||
switchConfigAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous switch configuration intent drain failed", failure);
|
||||
if (replaySwitchConfigEvents != null) replaySwitchConfigEvents.replay();
|
||||
});
|
||||
var replaySwitchEvents = switchAuditEvents;
|
||||
switchAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous switch event intent drain failed", failure);
|
||||
if (replaySwitchEvents != null) replaySwitchEvents.replay();
|
||||
});
|
||||
var replayAdminRevivalEvents = adminRevivalEvents;
|
||||
adminRevivalAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous admin revival event intent drain failed", failure);
|
||||
if (replayAdminRevivalEvents != null) replayAdminRevivalEvents.replay();
|
||||
});
|
||||
var replayIdentityEvents = identityAuditEvents;
|
||||
identityAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous identity event intent drain failed", failure);
|
||||
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);
|
||||
for (var player : onlinePlayers) { sessions.arrive(player); revive.reconcile(player); }
|
||||
sheetResourcePack.deliverAll(onlinePlayers);
|
||||
if(eventLog!=null)eventLog.ready();
|
||||
if (eventLog != null) {
|
||||
var preferenceReplay = preferenceEvents;
|
||||
var savedPreferences = feedbackPreferences;
|
||||
eventLog.ready().whenComplete((delivery, failure) -> {
|
||||
if (failure == null && delivery != games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED
|
||||
&& preferenceReplay != null && savedPreferences != null)
|
||||
for (var pendingPreference : savedPreferences.pendingEvents()) preferenceReplay.replay(pendingPreference);
|
||||
});
|
||||
}
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
try { onDisable(); }
|
||||
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
|
||||
@@ -813,8 +989,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
particleDelivery = null;
|
||||
combatFeedback = null;
|
||||
var closingEvents = sessionEvents; sessionEvents = null;
|
||||
var closingEventLog = eventLog; eventLog = null;
|
||||
var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null;
|
||||
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
|
||||
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
|
||||
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
|
||||
var closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
|
||||
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
|
||||
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
|
||||
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
|
||||
var closingSessionRecoveryAuditEvents = sessionRecoveryAuditEvents; sessionRecoveryAuditEvents = null;
|
||||
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
|
||||
var closingBooks = books; books = null;
|
||||
var closingPartyEvents = partyEvents; partyEvents = null;
|
||||
var closingPartyCombat = partyCombat; partyCombat = null;
|
||||
@@ -855,6 +1038,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingAbilityEngine = abilityEngine; abilityEngine = null;
|
||||
var closingFireball = fireball; fireball = null;
|
||||
var closingFireballSettings = fireballSettings; fireballSettings = null;
|
||||
var closingGreatSwordSettings = greatSwordSettings; greatSwordSettings = null;
|
||||
var closingFrostShard = frostShard; frostShard = null;
|
||||
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
|
||||
var closingLongbow = longbow; longbow = null;
|
||||
@@ -929,6 +1113,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
|
||||
if (closingSettings != null) cleanup("configuration", closingSettings::close);
|
||||
if (closingFireballSettings != null) cleanup("Fireball configuration", closingFireballSettings::close);
|
||||
if (closingGreatSwordSettings != null) cleanup("Great Sword configuration", closingGreatSwordSettings::close);
|
||||
if (closingFrostSettings != null) cleanup("Frost Shard configuration", closingFrostSettings::close);
|
||||
if (closingLongbowSettings != null) cleanup("Longbow configuration", closingLongbowSettings::close);
|
||||
if (closingQuickstepSettings != null) cleanup("Quickstep configuration", closingQuickstepSettings::close);
|
||||
@@ -1015,6 +1200,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
: closingSettings.drained().toCompletableFuture(),
|
||||
closingFireballSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingFireballSettings.drained().toCompletableFuture(),
|
||||
closingGreatSwordSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingGreatSwordSettings.drained().toCompletableFuture(),
|
||||
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingFrostSettings.drained().toCompletableFuture(),
|
||||
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -1117,11 +1304,61 @@ 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 (closingSwitchConfigEvents != null) cleanup("switch configuration event intent drain", () -> {
|
||||
var settingsDrained = closingSwitchDelaySettings == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
|
||||
: closingSwitchDelaySettings.drained();
|
||||
switchConfigAuditDrain = settingsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Switch setting drain failed", failure);
|
||||
closingSwitchConfigEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingSwitchConfigEvents.drained());
|
||||
});
|
||||
if (closingSwitchAuditEvents != null) cleanup("switch event intent drain", () -> {
|
||||
var sessionsDrained = closingSessions == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
|
||||
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);
|
||||
unregister(closingChat);
|
||||
unregister(closingEventCapture);
|
||||
unregister(closingMessageAuditEvents);
|
||||
unregister(closingPartyEvents);
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingPartyRegistrarController);
|
||||
@@ -1161,6 +1398,24 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
|
||||
});
|
||||
if (closingCharacters != null) cleanup("character storage", closingCharacters::close);
|
||||
if (closingAdminRevivalAuditEvents != null) cleanup("admin revival event intent drain", () -> {
|
||||
var charactersDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
adminRevivalAuditDrain = charactersDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character save drain failed", failure);
|
||||
closingAdminRevivalAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingAdminRevivalAuditEvents.drained());
|
||||
});
|
||||
if (closingIdentityAuditEvents != null) cleanup("character identity event intent drain", () -> {
|
||||
var charactersDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
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", () -> {
|
||||
@@ -1392,15 +1647,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
|
||||
try {
|
||||
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
|
||||
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "0.75"));
|
||||
var recovery = java.time.Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
|
||||
return new games.dmg.fantasystick.ability.GreatSword.Settings(base, recovery);
|
||||
} catch (ArithmeticException | NumberFormatException invalid) {
|
||||
throw new IllegalArgumentException("Great Sword settings must use supported nonnegative numeric values", invalid);
|
||||
}
|
||||
private java.util.Map<String, String> greatSwordConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (var key : games.dmg.fantasystick.persistence.GreatSwordSettings.keys())
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.GreatSwordSettings.defaultValue(key)));
|
||||
games.dmg.fantasystick.persistence.GreatSwordSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private void notifyPlayer(Object generation, EquipmentSessions.Notice notice) {
|
||||
|
||||
@@ -43,11 +43,42 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
|
||||
private final Consumer<Failure> failures;
|
||||
private final Map<Key, Object> pending = new HashMap<>();
|
||||
private final Set<String> eventOwnedAbilities = new HashSet<>();
|
||||
private final Map<Key, Long> generations = new HashMap<>();
|
||||
private final Map<Key, Set<Runtime>> active = new HashMap<>();
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private games.dmg.fantasystick.events.AbilityCastEventBridge castEvents;
|
||||
private boolean closed;
|
||||
|
||||
public void attachCastEvents(games.dmg.fantasystick.events.AbilityCastEventBridge events) {
|
||||
if (closed || castEvents != null) throw new IllegalStateException("Cast events already attached or engine closed");
|
||||
castEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
/** An ability owner opts in only after its actual effect-specific event path is delivered. */
|
||||
public void enableCastEventsFor(String abilityId) {
|
||||
if (closed || registry.find(Objects.requireNonNull(abilityId)).isEmpty())
|
||||
throw new IllegalArgumentException("Unknown or closed ability event owner");
|
||||
eventOwnedAbilities.add(abilityId);
|
||||
}
|
||||
|
||||
public boolean eventsEnabledFor(String abilityId) { return eventOwnedAbilities.contains(abilityId); }
|
||||
|
||||
private void resolve(games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit,
|
||||
String outcome, String reason) {
|
||||
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) {
|
||||
@@ -149,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;
|
||||
}
|
||||
@@ -164,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()); }
|
||||
@@ -176,22 +213,31 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
pending.remove(key, token); report(key.character(), key.ability(), invalid);
|
||||
input.player().sendMessage("That ability's cooldown is invalid."); return;
|
||||
}
|
||||
characters.commitAbilityCooldown(key.character(), record.revision(), key.ability(), readyAt)
|
||||
.whenComplete((mutation, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> completeCast(input, definition, record.revision(), readyAt,
|
||||
key, token, generation, mutation, failure));
|
||||
} catch (RuntimeException rejected) {
|
||||
pending.remove(key, token); report(key.character(), key.ability(), rejected);
|
||||
}
|
||||
});
|
||||
var audit = castEvents == null || !eventOwnedAbilities.contains(definition.id())
|
||||
? null : castEvents.begin(input, record, definition);
|
||||
try {
|
||||
characters.commitAbilityCooldown(key.character(), record.revision(), key.ability(), readyAt)
|
||||
.whenComplete((mutation, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> completeCast(input, definition, record.revision(), readyAt,
|
||||
key, token, generation, audit, mutation, failure));
|
||||
} catch (RuntimeException rejected) {
|
||||
pending.remove(key, token); resolve(audit, "failed", "scheduler_rejected");
|
||||
report(key.character(), key.ability(), rejected);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException failure) {
|
||||
pending.remove(key, token); resolve(audit, "failed", "save_rejected");
|
||||
report(key.character(), key.ability(), failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void completeCast(ControlInput input, AbilityDefinition definition, BigInteger expectedRevision, Instant readyAt,
|
||||
Key key, Object token, long generation, CharacterStorage.AbilityMutation mutation, Throwable failure) {
|
||||
if (!pending.remove(key, token)) return;
|
||||
if (closed) return;
|
||||
Key key, Object token, long generation, games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit,
|
||||
CharacterStorage.AbilityMutation mutation, Throwable failure) {
|
||||
if (!pending.remove(key, token) || closed) { resolve(audit, "interrupted", "session_or_engine_closed"); return; }
|
||||
if (failure != null) {
|
||||
resolve(audit, "failed", "save_failed");
|
||||
report(key.character(), key.ability(), failure);
|
||||
input.player().sendMessage("The cast could not be saved; no effect was applied."); return;
|
||||
}
|
||||
@@ -199,6 +245,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
&& mutation.record().map(record -> readyAt.equals(record.cooldowns().get(key.ability()))).orElse(false);
|
||||
if ((mutation.status() != CharacterStorage.AbilityMutationStatus.APPLIED && !unchangedDeadline)
|
||||
|| mutation.record().isEmpty()) {
|
||||
resolve(audit, "refused", mutation.status().name().toLowerCase(java.util.Locale.ROOT));
|
||||
input.player().sendMessage(switch (mutation.status()) {
|
||||
case COOLDOWN -> "That ability is still on cooldown.";
|
||||
case STALE -> "That character changed; retry the cast.";
|
||||
@@ -208,23 +255,32 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
return;
|
||||
}
|
||||
CharacterRecord saved = mutation.record().orElseThrow();
|
||||
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);
|
||||
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.close(); report(key.character(), key.ability(), 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.");
|
||||
}
|
||||
}
|
||||
@@ -293,12 +349,42 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
private final ControlInput input;
|
||||
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) {
|
||||
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation,
|
||||
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.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; }
|
||||
@@ -306,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();
|
||||
}
|
||||
@@ -361,12 +447,110 @@ 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 void finish() { close(); }
|
||||
@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);
|
||||
UUID application = castEvents == null ? UUID.randomUUID() : castEvents.effectApplied(audit, effectId, target, evidence);
|
||||
observedEffects.put(application, effectId);
|
||||
return application;
|
||||
}
|
||||
@Override public void acceptedDamage(LivingEntity target, double requested, double actual,
|
||||
Entity direct, Map<String, Object> evidence) {
|
||||
if (castEvents == null || audit == null) return;
|
||||
try {
|
||||
var details = new java.util.LinkedHashMap<String, Object>(evidence);
|
||||
if (target instanceof Player player)
|
||||
sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(active -> {
|
||||
details.put("target_character_id", active.character().toString());
|
||||
details.put("target_session_id", active.session().toString());
|
||||
});
|
||||
castEvents.acceptedDamage(audit, target, requested, actual, direct, details);
|
||||
} catch (RuntimeException ignored) { /* Recording cannot change accepted native damage. */ }
|
||||
}
|
||||
@Override public void acceptedHeal(EquipmentSessions.Active aided, Player target,
|
||||
CharacterRecord saved, double requested, double actual,
|
||||
double before, double after, double maximum) {
|
||||
if (castEvents != null && audit != null)
|
||||
castEvents.healed(audit, target, aided.character(), aided.session(), saved,
|
||||
requested, actual, before, after, maximum);
|
||||
}
|
||||
@Override public void acceptedCleanse(EquipmentSessions.Active aided, Player target, List<String> removed) {
|
||||
if (castEvents != null && audit != null)
|
||||
castEvents.cleansed(audit, target, aided.character(), aided.session(), removed);
|
||||
}
|
||||
@Override public void acceptedPrevention(EquipmentSessions.Active aided, Player ally, Entity attacker,
|
||||
double incoming, double actual, double remaining,
|
||||
UUID bondApplication) {
|
||||
if (castEvents != null && audit != null)
|
||||
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) {
|
||||
if (castEvents != null && audit != null)
|
||||
castEvents.boosted(audit, attacking, attacker, target, base, bonus, finalDamage, percent);
|
||||
}
|
||||
@Override public void effectEnded(UUID application, String 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);
|
||||
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", "obstructed", "replaced").contains(result))
|
||||
throw new IllegalArgumentException("Unsupported ability resolution");
|
||||
if (!finished) { outcome = result; resolutionEvidence = Map.copyOf(Objects.requireNonNull(evidence)); }
|
||||
}
|
||||
@Override public void finish() {
|
||||
if (outcome == null) outcome = "completed";
|
||||
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);
|
||||
var runtimes = active.get(key);
|
||||
if (runtimes != null) { runtimes.remove(this); if (runtimes.isEmpty()) active.remove(key); }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
@@ -15,12 +17,58 @@ 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);
|
||||
boolean trainingDummy(Entity target);
|
||||
void damage(LivingEntity target, double amount, DamageSource source);
|
||||
void report(AbilityReport.Type type, Entity target, BigDecimal amount);
|
||||
/** An owner reports an effect only after its actual native application. */
|
||||
UUID effectApplied(String effectId, Entity target, Map<String, Object> evidence);
|
||||
/** Only the effect owner calls this after positive, uncancelled native damage is accepted. */
|
||||
void acceptedDamage(LivingEntity target, double requested, double actual, Entity direct,
|
||||
Map<String, Object> evidence);
|
||||
/** Only after the aided record and native health have both been acknowledged. */
|
||||
void acceptedHeal(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
|
||||
org.bukkit.entity.Player target,
|
||||
games.dmg.fantasystick.persistence.CharacterRecord saved,
|
||||
double requested, double actual, double before, double after, double maximum);
|
||||
/** Record only harmful native effects actually removed from this target. */
|
||||
void acceptedCleanse(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
|
||||
org.bukkit.entity.Player target, List<String> removedEffects);
|
||||
/** Report only the final uncancelled native ally reduction, not a proposed redirect. */
|
||||
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);
|
||||
void finish();
|
||||
@Override void close();
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -48,8 +49,17 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
|
||||
final AbilityExecution execution; final LivingEntity target; final double requested;
|
||||
final BigDecimal defined; final UUID chainId; final int ordinal;
|
||||
final UUID previousId; final Map<String, Object> previousLocation;
|
||||
boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal defined,
|
||||
UUID chainId, int ordinal, LivingEntity previous) {
|
||||
this.execution = execution; this.target = target; this.requested = requested;
|
||||
this.defined = defined; this.chainId = chainId; this.ordinal = ordinal;
|
||||
previousId = previous == null ? null : previous.getUniqueId();
|
||||
previousLocation = previous == null ? Map.of() : locationData(previous.getLocation());
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
@@ -103,27 +113,41 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var captured = from(execution.definition()); var caster = execution.input().player();
|
||||
var initial = selectInitial(execution.input(), captured.initialRange());
|
||||
if (initial == null) return;
|
||||
if (initial == null) { execution.resolve("miss", Map.of("reason", "initial_target_unavailable")); return; }
|
||||
BigDecimal defined = new BigDecimal(initialDamage(captured.baseDamage(), execution.character().stats().get(Stat.INT)));
|
||||
var visited = new HashSet<UUID>(); LivingEntity current = initial;
|
||||
var visited = new HashSet<UUID>(); LivingEntity current = initial, previous = null;
|
||||
var chainId = UUID.randomUUID(); int attempted = 0, accepted = 0;
|
||||
int maximum = Math.min(64, 1 + captured.additionalTargets());
|
||||
for (int strike = 0; strike < maximum && current != null && execution.stillCurrent(); strike++) {
|
||||
for (int hop = 0; hop < maximum && current != null && execution.stillCurrent(); hop++) {
|
||||
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
|
||||
strike(execution, caster, current, defined); visual(caster, current);
|
||||
if (strike + 1 == maximum) break;
|
||||
var attempt = strike(execution, caster, current, defined, chainId, hop + 1, previous);
|
||||
attempted++; if (attempt.accepted) accepted++;
|
||||
visual(caster, current);
|
||||
if (hop + 1 == maximum) break;
|
||||
defined = defined.multiply(captured.jumpMultiplier());
|
||||
previous = current;
|
||||
current = next(caster, current, visited, captured.jumpRange());
|
||||
}
|
||||
var outcome = new LinkedHashMap<String, Object>();
|
||||
outcome.put("chain_id", chainId.toString());
|
||||
outcome.put("initial_target_id", initial.getUniqueId().toString());
|
||||
outcome.put("initial_target_location", locationData(initial.getLocation()));
|
||||
outcome.put("attempted_hops", attempted); outcome.put("accepted_hops", accepted);
|
||||
outcome.put("terminal_reason", !execution.stillCurrent() ? "source_interrupted"
|
||||
: attempted == maximum ? "target_limit" : "no_eligible_next_target");
|
||||
execution.resolve(!execution.stillCurrent() ? "interrupted" : accepted > 0 ? "hit" : "miss", outcome);
|
||||
}
|
||||
|
||||
private void strike(AbilityExecution execution, Player caster, LivingEntity target, BigDecimal defined) {
|
||||
private Attempt strike(AbilityExecution execution, Player caster, LivingEntity target,
|
||||
BigDecimal defined, UUID chainId, int ordinal, LivingEntity previous) {
|
||||
double amount = finite(definiteNonnegative(defined));
|
||||
var origin = caster.getEyeLocation().clone();
|
||||
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
|
||||
.withDirectEntity(caster).withDamageLocation(origin).build();
|
||||
var attempt = new Attempt(execution, target); damaging = attempt;
|
||||
var attempt = new Attempt(execution, target, amount, defined, chainId, ordinal, previous); damaging = attempt;
|
||||
try { execution.damage(target, amount, source); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
return attempt;
|
||||
}
|
||||
|
||||
private LivingEntity selectInitial(ControlInput input, double range) {
|
||||
@@ -179,13 +203,42 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING
|
||||
if (!ownsAcceptedDamage(event) || attempt.accepted
|
||||
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("damage_kind", "owned_chain_strike");
|
||||
evidence.put("chain_id", attempt.chainId.toString());
|
||||
evidence.put("hop_ordinal", attempt.ordinal);
|
||||
evidence.put("defined_amount", attempt.defined.stripTrailingZeros().toPlainString());
|
||||
if (attempt.previousId != null) {
|
||||
evidence.put("previous_target_id", attempt.previousId.toString());
|
||||
evidence.put("previous_target_location", attempt.previousLocation);
|
||||
}
|
||||
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
|
||||
attempt.execution.input().player(), evidence);
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
/** Exclude only this in-flight native hop from the generic damage observer. */
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING) return false;
|
||||
var source = attempt.execution.input().player();
|
||||
var causing = event.getDamageSource().getCausingEntity();
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
return (causing == null || causing == source) && (direct == null || direct == source);
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
var world = location == null ? null : location.getWorld();
|
||||
if (world == null || world.getUID() == null || world.getName() == null)
|
||||
return Map.of("location_status", "unavailable");
|
||||
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
|
||||
"x", location.getX(), "y", location.getY(), "z", location.getZ());
|
||||
}
|
||||
|
||||
private void visual(Player caster, LivingEntity target) {
|
||||
try {
|
||||
var location = target.getLocation().clone().add(0, target.getHeight() / 2, 0);
|
||||
|
||||
@@ -18,9 +18,12 @@ import org.bukkit.event.entity.EntityDamageEvent;
|
||||
/** Internal strongest-reduction stage shared by Fantasy Stick protection effects. */
|
||||
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; boolean closed;
|
||||
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current;
|
||||
final java.util.function.Consumer<Accepted> acceptedObservation; boolean closed;
|
||||
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
|
||||
java.util.function.Consumer<Accepted> acceptedObservation) {
|
||||
this.execution = execution; this.player = player; this.percent = percent; this.current = current;
|
||||
this.acceptedObservation = acceptedObservation;
|
||||
}
|
||||
boolean valid() {
|
||||
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
|
||||
@@ -32,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;
|
||||
@@ -42,10 +46,23 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
}
|
||||
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
return grant(execution, player, percent, current, null);
|
||||
}
|
||||
|
||||
/** 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> 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");
|
||||
var grant = new Grant(execution, player, percent, current);
|
||||
var grant = new Grant(execution, player, percent, current, acceptedObservation);
|
||||
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
|
||||
return grant;
|
||||
}
|
||||
@@ -66,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)
|
||||
@@ -75,7 +91,16 @@ 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(new Accepted((EntityDamageByEntityEvent) event,
|
||||
prevention.incoming, remaining, actual));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot alter the accepted native hit. */ }
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
|
||||
@@ -56,6 +56,8 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
|
||||
final BigInteger intelligence;
|
||||
final Settings settings;
|
||||
LivingEntity target;
|
||||
double requestedDamage;
|
||||
boolean accepted;
|
||||
boolean finished;
|
||||
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
|
||||
this.projectile = projectile; this.execution = execution; this.origin = origin;
|
||||
@@ -123,8 +125,14 @@ public final class Fireball 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)), execution::finish);
|
||||
execution.later(current.lifetime(), execution::finish);
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
|
||||
execution.resolve("expired", Map.of("projectile_id", projectile.getUniqueId().toString(),
|
||||
"limit", "range", "effective_settings", settingsData(current))); execution.finish();
|
||||
});
|
||||
execution.later(current.lifetime(), () -> {
|
||||
execution.resolve("expired", Map.of("projectile_id", projectile.getUniqueId().toString(),
|
||||
"limit", "lifetime", "effective_settings", settingsData(current))); execution.finish();
|
||||
});
|
||||
particles.world(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
|
||||
}
|
||||
|
||||
@@ -141,14 +149,44 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
|
||||
var source = DamageSource.builder(DamageType.FIREBALL).withCausingEntity(state.execution.input().player())
|
||||
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
|
||||
state.target = target;
|
||||
state.requestedDamage = nativeDamage;
|
||||
damaging = state;
|
||||
try { state.execution.damage(target, nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
} finally {
|
||||
state.execution.finish();
|
||||
String outcome = state.accepted ? "hit" : event.isCancelled() ? "cancelled" : "miss";
|
||||
try {
|
||||
if (outcome.equals("miss") && (closed || !state.execution.stillCurrent())) outcome = "interrupted";
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("projectile_id", state.projectile.getUniqueId().toString());
|
||||
evidence.put("direct_entity_id", state.projectile.getUniqueId().toString());
|
||||
evidence.put("effective_settings", settingsData(state.settings));
|
||||
var impact = event.getEntity().getLocation();
|
||||
if (impact != null && impact.getWorld() != null) evidence.put("target_location", locationData(impact));
|
||||
if (event.getHitEntity() != null) evidence.put("target_entity_id", event.getHitEntity().getUniqueId().toString());
|
||||
state.execution.resolve(outcome, evidence);
|
||||
} catch (RuntimeException auditFailure) {
|
||||
state.execution.resolve(outcome);
|
||||
} finally {
|
||||
state.execution.finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> settingsData(Settings settings) {
|
||||
return Map.of("base-damage", settings.baseDamage().toString(),
|
||||
"cooldown-seconds", seconds(settings.cooldown()).toPlainString(),
|
||||
"range", Double.toString(settings.range()),
|
||||
"lifetime", seconds(settings.lifetime()).toPlainString(),
|
||||
"projectile-speed", Double.toString(settings.speedPerTick()));
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
return Map.of("world_id", location.getWorld().getUID().toString(),
|
||||
"world_name", location.getWorld().getName(), "x", location.getX(),
|
||||
"y", location.getY(), "z", location.getZ());
|
||||
}
|
||||
|
||||
private boolean inRange(State state, Location current) {
|
||||
return current.getWorld() != null && state.origin.getWorld() != null
|
||||
&& current.getWorld().equals(state.origin.getWorld())
|
||||
@@ -162,16 +200,25 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
|
||||
if (state != null && damaging != state) event.setCancelled(true);
|
||||
}
|
||||
|
||||
/** One native application belongs to this projectile, including a fantasy-player target. */
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || event.getEntity() != state.target) return false;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|
||||
|| byEntity.getDamager() != state.projectile)) return false;
|
||||
var causing = event.getDamageSource().getCausingEntity();
|
||||
return causing == null || causing == state.execution.input().player();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || event.getEntity() != state.target || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage())) return;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|
||||
|| byEntity.getDamager() != state.projectile)) return;
|
||||
var causing = event.getDamageSource().getCausingEntity();
|
||||
if (causing != null && causing != state.execution.input().player()) return;
|
||||
if (state == null || state.accepted || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage()) || !ownsAcceptedDamage(event)) return;
|
||||
state.accepted = true;
|
||||
state.execution.acceptedDamage(state.target, state.requestedDamage, event.getFinalDamage(),
|
||||
state.projectile, Map.of("damage_kind", "owned_direct_projectile"));
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
@@ -182,7 +229,9 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
|
||||
for (var state : List.copyOf(projectiles.values())) {
|
||||
state.execution.resolve("interrupted"); state.execution.finish();
|
||||
}
|
||||
projectiles.clear(); damaging = null;
|
||||
}
|
||||
}
|
||||
@@ -50,36 +50,61 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
|
||||
|
||||
private static final class SlowLease implements AutoCloseable {
|
||||
final LivingEntity target; final PotionEffect previous; final PotionEffect applied;
|
||||
final AbilityExecution execution; UUID application;
|
||||
final long started = System.nanoTime(); boolean owned; boolean closed;
|
||||
SlowLease(LivingEntity target, PotionEffect applied) {
|
||||
SlowLease(LivingEntity target, PotionEffect applied, AbilityExecution execution) {
|
||||
this.target = target; previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
|
||||
this.execution = execution;
|
||||
}
|
||||
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
|
||||
@Override public void close() {
|
||||
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());
|
||||
if (previous != null) {
|
||||
evidence.put("replaced_amplifier", previous.getAmplifier());
|
||||
evidence.put("replaced_duration_ticks", previous.getDuration());
|
||||
}
|
||||
application = execution.effectApplied("frost_nova_slowness", target, evidence);
|
||||
} catch (RuntimeException ignored) { /* Auditing cannot refuse a native effect. */ }
|
||||
return owned;
|
||||
}
|
||||
@Override public void close() { close("interrupted"); }
|
||||
void close(String reason) {
|
||||
if (closed) return; closed = true; if (!owned) return;
|
||||
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;
|
||||
|| current.getDuration() > expected + 2) {
|
||||
if (application != null) execution.effectEnded(application, "superseded");
|
||||
return;
|
||||
}
|
||||
target.removePotionEffect(PotionEffectType.SLOWNESS);
|
||||
if (previous != null) {
|
||||
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
|
||||
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
|
||||
}
|
||||
if (application != null) execution.effectEnded(application, reason);
|
||||
}
|
||||
}
|
||||
private final class LeaseGroup implements AutoCloseable {
|
||||
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>(); boolean closed;
|
||||
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>();
|
||||
String endReason = "interrupted"; boolean closed;
|
||||
LeaseGroup(AbilityExecution execution) { this.execution = execution; }
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; active.remove(execution, this);
|
||||
for (var lease : List.copyOf(leases)) lease.close(); leases.clear();
|
||||
for (var lease : List.copyOf(leases)) lease.close(endReason); leases.clear();
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
|
||||
final AbilityExecution execution; final LivingEntity target; final double requested; final int ordinal;
|
||||
boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, double requested, int ordinal) {
|
||||
this.execution = execution; this.target = target; this.requested = requested; this.ordinal = ordinal;
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
@@ -133,28 +158,39 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
|
||||
var group = new LeaseGroup(execution); active.put(execution, group); execution.own(group);
|
||||
BigInteger exact = damage(captured.baseDamage(), execution.character().stats().get(Stat.INT));
|
||||
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
|
||||
int affected = 0;
|
||||
for (var target : candidates(source, origin, captured.radius())) {
|
||||
int affected = 0, accepted = 0;
|
||||
var candidates = candidates(source, origin, captured.radius());
|
||||
for (var target : candidates) {
|
||||
if (affected == 64 || !execution.stillCurrent()) break;
|
||||
if (!eligible(execution, target, origin, captured.radius())) continue;
|
||||
var sourceDamage = DamageSource.builder(DamageType.FREEZE).withCausingEntity(source)
|
||||
.withDirectEntity(source).withDamageLocation(origin).build();
|
||||
var attempt = new Attempt(execution, target); damaging = attempt;
|
||||
var attempt = new Attempt(execution, target, amount, affected + 1); damaging = attempt;
|
||||
try { execution.damage(target, amount, sourceDamage); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
affected++;
|
||||
if (!attempt.accepted || !execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
|
||||
if (!attempt.accepted) continue;
|
||||
accepted++;
|
||||
if (!execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
|
||||
int ticks = Math.toIntExact(Math.max(1,
|
||||
(captured.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
|
||||
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
|
||||
captured.slownessAmplifier(), false, true, true));
|
||||
captured.slownessAmplifier(), false, true, true), execution);
|
||||
if (lease.apply()) group.leases.add(lease);
|
||||
}
|
||||
try { particles.world(Particle.SNOWFLAKE, origin, 32,
|
||||
captured.radius() / 2, captured.radius() / 2, captured.radius() / 2, 0.01); }
|
||||
catch (RuntimeException ignored) { }
|
||||
var result = new LinkedHashMap<String, Object>();
|
||||
result.put("candidate_count", candidates.size()); result.put("attempt_count", affected);
|
||||
result.put("accepted_count", accepted); result.put("slowness_count", group.leases.size());
|
||||
result.put("radius", captured.radius()); result.put("requested_amount", amount);
|
||||
result.put("burst_location", locationData(origin));
|
||||
execution.resolve(accepted > 0 ? "hit" : "miss", result);
|
||||
if (group.leases.isEmpty()) execution.finish();
|
||||
else execution.later(captured.slownessDuration(), execution::finish);
|
||||
else execution.later(captured.slownessDuration(), () -> {
|
||||
group.endReason = "expired"; execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
private List<LivingEntity> candidates(Player source, Location origin, double radius) {
|
||||
@@ -188,13 +224,33 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE
|
||||
if (!ownsAcceptedDamage(event) || attempt.accepted
|
||||
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
|
||||
attempt.execution.input().player(), Map.of("damage_kind", "owned_area_burst", "ordinal", attempt.ordinal));
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
/** The per-target native hit belongs to this cast, not the generic damage observer. */
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE) return false;
|
||||
var source = attempt.execution.input().player();
|
||||
var causing = event.getDamageSource().getCausingEntity();
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
return (causing == null || causing == source) && (direct == null || direct == source);
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location origin) {
|
||||
var world = origin.getWorld();
|
||||
if (world == null || world.getUID() == null || world.getName() == null)
|
||||
return Map.of("location_status", "unavailable");
|
||||
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
|
||||
"x", origin.getX(), "y", origin.getY(), "z", origin.getZ());
|
||||
}
|
||||
|
||||
private static boolean unobstructed(Location origin, LivingEntity target) {
|
||||
var world = origin.getWorld(); if (world == null) return false;
|
||||
var location = target.getLocation(); if (location.getWorld() != world) return false;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
final Player ally;
|
||||
final EquipmentSessions.Active aided;
|
||||
final Settings settings;
|
||||
UUID application;
|
||||
String endReason = "interrupted";
|
||||
boolean closed;
|
||||
State(AbilityExecution execution, Player guardian, Player ally,
|
||||
EquipmentSessions.Active aided, Settings settings) {
|
||||
@@ -67,14 +69,16 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
closed = true;
|
||||
byGuardian.remove(guardian.getUniqueId(), this);
|
||||
byAlly.remove(ally.getUniqueId(), this);
|
||||
if (application != null) execution.effectEnded(application, endReason);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NativeTransfer {
|
||||
final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
|
||||
final State owner; final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
|
||||
BigDecimal accepted = BigDecimal.ZERO;
|
||||
NativeTransfer(Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
|
||||
this.guardian = guardian; this.attacker = attacker; this.requested = requested; this.available = available;
|
||||
NativeTransfer(State owner, Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
|
||||
this.owner = owner; this.guardian = guardian; this.attacker = attacker;
|
||||
this.requested = requested; this.available = available;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,11 +88,14 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Damage damage;
|
||||
private final boolean nativeDamageMode;
|
||||
private final Consumer<Support> supports;
|
||||
private final Map<UUID, State> byGuardian = new java.util.HashMap<>();
|
||||
private final Map<UUID, State> byAlly = new java.util.HashMap<>();
|
||||
private int transferring;
|
||||
private NativeTransfer nativeTransfer;
|
||||
private record Reduction(State state, Entity attacker, double incoming, double accepted) { }
|
||||
private final Map<EntityDamageByEntityEvent, Reduction> pendingReductions = new java.util.IdentityHashMap<>();
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
@@ -102,7 +109,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
this.settings = Objects.requireNonNull(settings);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
this.damage = damage == null ? this::nativeDamage : damage;
|
||||
this.nativeDamageMode = damage == null;
|
||||
this.damage = nativeDamageMode ? this::nativeDamage : damage;
|
||||
this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
@@ -153,7 +161,14 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
}
|
||||
var state = new State(execution, guardian, ally, aided, captured);
|
||||
byGuardian.put(guardian.getUniqueId(), state); byAlly.put(ally.getUniqueId(), state);
|
||||
execution.own(state); execution.later(captured.duration(), execution::finish);
|
||||
execution.own(state);
|
||||
state.application = execution.effectApplied("guardian_bond", ally,
|
||||
Map.of("redirect_percent", captured.redirectPercent().toPlainString(),
|
||||
"range", Double.toString(captured.range()),
|
||||
"duration_seconds", seconds(captured.duration())));
|
||||
execution.later(captured.duration(), () -> {
|
||||
state.endReason = "expired"; execution.resolve("expired"); execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
@@ -184,8 +199,30 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
double remaining = BigDecimal.valueOf(incoming).subtract(accepted).max(BigDecimal.ZERO).doubleValue();
|
||||
if (!Double.isFinite(remaining)) return;
|
||||
event.setDamage(remaining);
|
||||
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
|
||||
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
|
||||
pendingReductions.put(event, new Reduction(state, attacker, incoming, accepted.doubleValue()));
|
||||
if (!nativeDamageMode) {
|
||||
// Injected test adapters return their final accepted result directly.
|
||||
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
|
||||
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
|
||||
}
|
||||
}
|
||||
|
||||
/** Only an uncancelled original hit can claim actual prevention. */
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void acceptedPrevention(EntityDamageByEntityEvent event) {
|
||||
var reduction = pendingReductions.remove(event);
|
||||
if (reduction == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())) return;
|
||||
double actual = Math.min(reduction.accepted(), Math.max(0, reduction.incoming() - event.getFinalDamage()));
|
||||
if (actual <= 0) return;
|
||||
var state = reduction.state();
|
||||
state.execution.acceptedPrevention(state.aided, state.ally, reduction.attacker(),
|
||||
reduction.incoming(), actual, event.getFinalDamage(), state.application);
|
||||
if (nativeDamageMode) {
|
||||
var amount = BigDecimal.valueOf(actual);
|
||||
state.execution.report(AbilityReport.Type.PREVENTION, state.ally, amount);
|
||||
supports.accept(new Support(reduction.attacker().getUniqueId(),
|
||||
state.execution.input().session().character(), state.aided, amount));
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture accepted marked damage before synchronous fantasy-defeat restoration can replace native health. */
|
||||
@@ -193,7 +230,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
Objects.requireNonNull(guardian); Objects.requireNonNull(attacker); Objects.requireNonNull(requested);
|
||||
double amount = requested.doubleValue(); double health = effectiveHealth(guardian);
|
||||
if (!Double.isFinite(amount) || amount <= 0 || !Double.isFinite(health) || health <= 0) return BigDecimal.ZERO;
|
||||
var transfer = new NativeTransfer(guardian, attacker, requested, BigDecimal.valueOf(health));
|
||||
var transfer = new NativeTransfer(byGuardian.get(guardian.getUniqueId()), guardian, attacker,
|
||||
requested, BigDecimal.valueOf(health));
|
||||
nativeTransfer = transfer;
|
||||
try { guardian.damage(amount, attacker); }
|
||||
finally { nativeTransfer = null; }
|
||||
@@ -206,6 +244,17 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
if (transfer == null || event.getEntity() != transfer.guardian || attacker(event) != transfer.attacker
|
||||
|| event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0) return;
|
||||
transfer.accepted = BigDecimal.valueOf(event.getFinalDamage()).min(transfer.requested).min(transfer.available);
|
||||
if (transfer.owner != null && transfer.accepted.signum() > 0)
|
||||
transfer.owner.execution.acceptedDamage(transfer.guardian, transfer.requested.doubleValue(),
|
||||
transfer.accepted.doubleValue(), transfer.attacker,
|
||||
Map.of("damage_kind", "guardian_bond_redirection"));
|
||||
}
|
||||
|
||||
/** The guardian's accepted native hit has one richer bond-owned damage record. */
|
||||
public boolean ownsAcceptedRedirect(org.bukkit.event.entity.EntityDamageEvent event) {
|
||||
var transfer = nativeTransfer;
|
||||
return transfer != null && event instanceof EntityDamageByEntityEvent hit
|
||||
&& hit.getEntity() == transfer.guardian && attacker(hit) == transfer.attacker;
|
||||
}
|
||||
|
||||
/** True only during synchronous dispatch of Guardian Bond's marked redirected damage. */
|
||||
@@ -230,7 +279,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
}
|
||||
|
||||
private void breakBond(State state) {
|
||||
state.close(); state.execution.finish();
|
||||
state.endReason = "eligibility_changed";
|
||||
state.close(); state.execution.resolve("interrupted"); state.execution.finish();
|
||||
}
|
||||
|
||||
private static boolean eligible(Player guardian, Player ally, double range) {
|
||||
@@ -268,7 +318,9 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
if (sweepTask != null) sweepTask.cancel();
|
||||
for (var state : List.copyOf(byGuardian.values())) state.execution.finish();
|
||||
byGuardian.clear(); byAlly.clear();
|
||||
for (var state : List.copyOf(byGuardian.values())) {
|
||||
state.endReason = "shutdown"; state.execution.resolve("interrupted"); state.execution.finish();
|
||||
}
|
||||
byGuardian.clear(); byAlly.clear(); pendingReductions.clear();
|
||||
}
|
||||
}
|
||||
@@ -35,15 +35,37 @@ public final class Hex implements AbilityEffect,AutoCloseable{
|
||||
"duration-seconds",BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),"damage-reduction-percent",current.reductionPercent()),
|
||||
AbilityDefinition.TargetPolicy.HOSTILE,this);}
|
||||
@Override public boolean accepts(ControlInput input){return !closed&&input.target() instanceof LivingEntity target&&inRange(input,target,settings.range());}
|
||||
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof LivingEntity target))return;
|
||||
var current=from(execution.definition());if(!inRange(execution.input(),target,current.range())||execution.trainingDummy(target)){execution.finish();return;}
|
||||
var state=new State(execution,target,mitigation.grant(execution,target,current.reductionPercent()));var previous=hexes.put(target.getUniqueId(),state);
|
||||
if(previous!=null)previous.execution.finish();execution.own(state);execution.later(current.duration(),execution::finish);}
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
|
||||
var current = from(execution.definition());
|
||||
if (!inRange(execution.input(), target, current.range()) || execution.trainingDummy(target)) {
|
||||
execution.resolve("miss", Map.of("reason", "target_ineligible_after_cooldown"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
var application = new java.util.concurrent.atomic.AtomicReference<UUID>();
|
||||
var grant = mitigation.grantObserved(execution, target, current.reductionPercent(), accepted -> {
|
||||
try { execution.acceptedOutgoingMitigation(application.get(), accepted.debuffedAttacker(),
|
||||
accepted.recipient(), accepted.incoming(), accepted.remaining(), accepted.prevented()); }
|
||||
catch (RuntimeException ignored) { /* Native damage and support do not depend on auditing. */ }
|
||||
});
|
||||
var state = new State(execution, target, grant);
|
||||
var previous = hexes.put(target.getUniqueId(), state);
|
||||
if (previous != null) { previous.execution.resolve("replaced"); previous.execution.finish(); }
|
||||
execution.own(state);
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("reduction_percent", current.reductionPercent().stripTrailingZeros().toPlainString());
|
||||
evidence.put("duration_seconds", seconds(current.duration()));
|
||||
var activatedAt = execution.now();
|
||||
if (activatedAt != null) evidence.put("effect_until", activatedAt.plus(current.duration()).toString());
|
||||
application.set(execution.effectApplied("hex", target, evidence));
|
||||
execution.later(current.duration(), () -> { execution.resolve("expired"); execution.finish(); });
|
||||
}
|
||||
private static boolean inRange(ControlInput input,LivingEntity target,double range){try{Location source=input.player().getLocation(),destination=target.getLocation();
|
||||
return source.getWorld()==destination.getWorld()&&source.distanceSquared(destination)<=range*range&&input.player().hasLineOfSight(target);
|
||||
}catch(RuntimeException invalid){return false;}}
|
||||
private static Settings from(AbilityDefinition definition){var p=definition.parameters();return new Settings(definition.cooldown(),p.get("range").doubleValue(),
|
||||
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-reduction-percent"));}
|
||||
private static String seconds(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
|
||||
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values()))state.execution.finish();hexes.clear();}
|
||||
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values())){
|
||||
state.execution.resolve("interrupted"); state.execution.finish();}hexes.clear();}
|
||||
}
|
||||
@@ -8,7 +8,38 @@ public final class Inspire implements AbilityEffect,AutoCloseable{
|
||||
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Category.BUF,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
|
||||
@Override public boolean accepts(ControlInput input){if(closed||!(input.target() instanceof Player target))return false;if(input.player().isSneaking())return target==input.player();return target!=input.player()&&inRange(input.player(),target,settings.range());}
|
||||
public static BigInteger amount(BigInteger charisma){return amount(BigInteger.valueOf(4),charisma);}static BigInteger amount(BigInteger base,BigInteger charisma){Objects.requireNonNull(base);Objects.requireNonNull(charisma);if(base.signum()<0||charisma.signum()<0)throw new IllegalArgumentException("Invalid Inspire amount");var delta=charisma.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&÷d[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
|
||||
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;var s=from(execution.definition());if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){execution.finish();return;}var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.finish();return;}execution.own(temporaryHealth.grant(execution,target,nativeAmount));execution.later(s.duration(),execution::finish);}
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;
|
||||
var s=from(execution.definition());
|
||||
if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){
|
||||
execution.resolve("miss",Map.of("reason","target_ineligible_after_cooldown"));execution.finish();return;
|
||||
}
|
||||
var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));
|
||||
double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();
|
||||
if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.resolve("miss",Map.of("reason","no_positive_temporary_health"));execution.finish();return;}
|
||||
var application=new java.util.concurrent.atomic.AtomicReference<UUID>();
|
||||
var grant=temporaryHealth.grantObserved(execution,target,nativeAmount,accepted->{
|
||||
try{execution.acceptedAbsorption(application.get(),accepted.target(),accepted.attacker(),accepted.incoming(),
|
||||
accepted.before(),accepted.after(),accepted.consumed(),accepted.remaining(),accepted.targetAtHit(),accepted.attackerAtHit());}
|
||||
catch(RuntimeException ignored){/* Audit cannot change native absorption or party rewards. */}
|
||||
},remaining->{
|
||||
var id=application.get();
|
||||
if(id!=null)execution.effectEndEvidence(id,Map.of("remaining_amount",
|
||||
BigDecimal.valueOf(remaining).stripTrailingZeros().toPlainString()));
|
||||
});
|
||||
execution.own(grant);
|
||||
var evidence=new LinkedHashMap<String,Object>();
|
||||
evidence.put("granted_amount",BigDecimal.valueOf(nativeAmount).stripTrailingZeros().toPlainString());
|
||||
evidence.put("remaining_amount",BigDecimal.valueOf(nativeAmount).stripTrailingZeros().toPlainString());
|
||||
evidence.put("duration_seconds",seconds(s.duration()));
|
||||
var activatedAt=execution.now();if(activatedAt!=null)evidence.put("effect_until",activatedAt.plus(s.duration()).toString());
|
||||
temporaryHealth.currentSession(target).ifPresent(active->{
|
||||
evidence.put("target_character_id",active.character().toString());
|
||||
evidence.put("target_session_id",active.session().toString());
|
||||
});
|
||||
application.set(execution.effectApplied("inspire",target,evidence));
|
||||
execution.later(s.duration(),()->{execution.resolve("expired");execution.finish();});
|
||||
}
|
||||
private static boolean inRange(Player source,Player target,double range){try{var a=source.getLocation();var b=target.getLocation();return a.getWorld()==b.getWorld()&&a.distanceSquared(b)<=range*range&&source.hasLineOfSight(target);}catch(RuntimeException invalid){return false;}}
|
||||
private static Settings from(AbilityDefinition d){var p=d.parameters();return new Settings(p.get("base-temporary-health").toBigIntegerExact(),d.cooldown(),p.get("range").doubleValue(),Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()));}
|
||||
private static String seconds(Duration d){return BigDecimal.valueOf(d.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
|
||||
|
||||
@@ -58,7 +58,9 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
final Settings settings;
|
||||
final BigDecimal damage;
|
||||
LivingEntity target;
|
||||
double requested;
|
||||
boolean accepted;
|
||||
boolean nativeCancelled;
|
||||
boolean finished;
|
||||
State(Arrow arrow, AbilityExecution execution, Location origin, Settings settings, BigDecimal damage) {
|
||||
this.arrow = arrow; this.execution = execution; this.origin = origin; this.settings = settings; this.damage = damage;
|
||||
@@ -141,12 +143,15 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
}
|
||||
execution.own(state);
|
||||
long rangeTicks = Math.max(1, (long) Math.ceil(shot.range() / speed));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
|
||||
});
|
||||
execution.later(shot.lifetime(), () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
|
||||
});
|
||||
Runnable expire = () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) {
|
||||
try { execution.resolve("expired", Map.of("arrow_id", arrow.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot retain expired native arrows. */ }
|
||||
execution.finish();
|
||||
}
|
||||
};
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), expire);
|
||||
execution.later(shot.lifetime(), expire);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
@@ -161,18 +166,32 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
var state = arrows.remove(event.getEntity().getUniqueId());
|
||||
if (state == null) return;
|
||||
try {
|
||||
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|
||||
|| !(event.getHitEntity() instanceof LivingEntity target)
|
||||
boolean currentSession = !closed && state.execution.stillCurrent();
|
||||
if (!currentSession || event.isCancelled() || !(event.getHitEntity() instanceof LivingEntity target)
|
||||
|| !inRange(state, event.getEntity().getLocation())
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) {
|
||||
try { state.execution.resolve(!currentSession ? "interrupted" : event.isCancelled() ? "cancelled" : "miss",
|
||||
Map.of("arrow_id", state.arrow.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot prevent native cleanup. */ }
|
||||
return;
|
||||
}
|
||||
double nativeDamage = state.damage.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|
||||
? Double.MAX_VALUE : state.damage.doubleValue();
|
||||
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) return;
|
||||
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
|
||||
try { state.execution.resolve("miss", Map.of("arrow_id", state.arrow.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot prevent native cleanup. */ }
|
||||
return;
|
||||
}
|
||||
var source = DamageSource.builder(DamageType.ARROW).withCausingEntity(state.execution.input().player())
|
||||
.withDirectEntity(state.arrow).withDamageLocation(state.arrow.getLocation()).build();
|
||||
state.target = target; damaging = state;
|
||||
state.target = target; state.requested = nativeDamage; damaging = state;
|
||||
try { state.execution.damage(target, nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
try { state.execution.resolve(state.accepted ? "hit" : state.nativeCancelled ? "cancelled" : "miss",
|
||||
Map.of("arrow_id", state.arrow.getUniqueId().toString(),
|
||||
"target_entity_id", target.getUniqueId().toString(),
|
||||
"impact_location", locationData(state.arrow.getLocation()))); }
|
||||
catch (RuntimeException ignored) { /* Audit capture cannot change native damage. */ }
|
||||
} finally {
|
||||
state.close(); state.execution.finish();
|
||||
}
|
||||
@@ -184,18 +203,38 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage())) return;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target) return;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct != state.arrow && (!(event instanceof EntityDamageByEntityEvent byEntity)
|
||||
|| byEntity.getDamager() != state.arrow)) return;
|
||||
if (event.isCancelled()) { state.nativeCancelled = true; return; }
|
||||
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
state.accepted = true;
|
||||
try { state.execution.acceptedDamage(state.target, state.requested, event.getFinalDamage(), state.arrow,
|
||||
Map.of("arrow_id", state.arrow.getUniqueId().toString(),
|
||||
"impact_location", locationData(state.arrow.getLocation()))); }
|
||||
catch (RuntimeException ignored) { /* Audit capture cannot change an accepted contribution. */ }
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || event.getEntity() != state.target) return false;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
return direct == state.arrow || event instanceof EntityDamageByEntityEvent by
|
||||
&& by.getDamager() == state.arrow;
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
if (location == null || location.getWorld() == null) return Map.of("status", "unavailable");
|
||||
return Map.of("world_id", location.getWorld().getUID().toString(),
|
||||
"world_name", location.getWorld().getName(), "x", location.getX(), "y", location.getY(),
|
||||
"z", location.getZ(), "captured_at", java.time.Instant.now().toString());
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var p = definition.parameters();
|
||||
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(),
|
||||
|
||||
@@ -101,25 +101,31 @@ public final class MendingLight implements AbilityEffect {
|
||||
if (!execution.stillCurrent()) return;
|
||||
var configured = from(execution.definition());
|
||||
var target = target(execution.input(), configured.range());
|
||||
if (target == null) { execution.finish(); return; }
|
||||
if (target == null) { execution.resolve("miss"); execution.finish(); return; }
|
||||
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) { execution.finish(); return; }
|
||||
if (attribute == null) { execution.resolve("miss"); execution.finish(); return; }
|
||||
double current = target.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("miss"); execution.finish(); return;
|
||||
}
|
||||
BigInteger exact = healing(configured.baseHealing(), 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("miss"); execution.finish(); return;
|
||||
}
|
||||
var event = new EntityRegainHealthEvent(target.player(), proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event);
|
||||
double accepted = Math.min(proposed, 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(event.isCancelled() ? "cancelled" : "miss"); execution.finish(); return;
|
||||
}
|
||||
var expected = target.record().revision();
|
||||
BigDecimal persistedHealth = BigDecimal.valueOf(current + accepted);
|
||||
BigDecimal limit = nativeLimit.get();
|
||||
characters.saveHealth(target.record().id(), expected, persistedHealth, limit).whenComplete((saved, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> complete(execution, target, expected, accepted, saved, failure));
|
||||
} catch (RuntimeException rejected) { execution.finish(); }
|
||||
} catch (RuntimeException rejected) { execution.resolve("failed"); execution.finish(); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,20 +133,34 @@ public final class MendingLight implements AbilityEffect {
|
||||
java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty()) {
|
||||
execution.resolve("failed");
|
||||
execution.input().player().sendMessage("Mending Light health could not be saved; no healing was applied.");
|
||||
return;
|
||||
}
|
||||
if (!execution.stillCurrent() || !current(execution.input().player(), target, from(execution.definition()).range())) return;
|
||||
if (!execution.stillCurrent() || !current(execution.input().player(), target, from(execution.definition()).range())) {
|
||||
execution.resolve("interrupted"); return;
|
||||
}
|
||||
var persisted = saved.orElseThrow();
|
||||
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
|
||||
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) {
|
||||
execution.resolve("interrupted"); return;
|
||||
}
|
||||
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) return;
|
||||
double current = target.player().getHealth(); double maximum = attribute.getValue();
|
||||
double desired = Math.min(maximum, persisted.health().doubleValue());
|
||||
double restored = Math.min(accepted, desired - current);
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) {
|
||||
execution.resolve("miss"); return;
|
||||
}
|
||||
target.player().setHealth(Math.min(maximum, current + restored));
|
||||
var amount = BigDecimal.valueOf(restored).stripTrailingZeros();
|
||||
double after = target.player().getHealth();
|
||||
double actual = after - current;
|
||||
if (!Double.isFinite(actual) || actual <= 0) { execution.resolve("miss"); return; }
|
||||
execution.acceptedHeal(target.active(), target.player(), persisted, accepted, actual, current, after, maximum);
|
||||
execution.resolve("healed", Map.of("target_player_id", target.player().getUniqueId().toString(),
|
||||
"target_character_id", target.active().character().toString(),
|
||||
"actual_amount", BigDecimal.valueOf(actual).stripTrailingZeros().toPlainString()));
|
||||
var amount = BigDecimal.valueOf(actual).stripTrailingZeros();
|
||||
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
|
||||
execution.report(AbilityReport.Type.HEALING, target.player(), amount);
|
||||
} finally { execution.finish(); }
|
||||
|
||||
@@ -39,14 +39,54 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
|
||||
Duration.ofSeconds(2),5,4);}
|
||||
}
|
||||
private record Ground(Block block,Location origin){ }
|
||||
private static final class Attempt {final AbilityExecution execution;final LivingEntity target;boolean accepted;Attempt(AbilityExecution e,LivingEntity t){execution=e;target=t;}}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution;final LivingEntity target;final double requested;final int ordinal;
|
||||
boolean accepted;
|
||||
Attempt(AbilityExecution execution,LivingEntity target,double requested,int ordinal){
|
||||
this.execution=execution;this.target=target;this.requested=requested;this.ordinal=ordinal;
|
||||
}
|
||||
}
|
||||
private final class Warning implements AutoCloseable {
|
||||
final AbilityExecution execution;final Player source;final Ground ground;final Settings captured;final BigInteger intelligence;
|
||||
final int maximumTicks;int elapsed;boolean impacted;boolean closed;
|
||||
Warning(AbilityExecution execution,Ground ground,Settings captured){this.execution=execution;source=execution.input().player();this.ground=ground;this.captured=captured;intelligence=execution.character().stats().get(Stat.INT);maximumTicks=(int)Math.max(1,(captured.warning().toNanos()+49_999_999L)/50_000_000L);}
|
||||
void start(){warnings.put(execution,this);execution.own(this);warningVisual();execution.later(Duration.ofMillis(50),this::step);}
|
||||
void step(){if(closed||impacted)return;if(!execution.stillCurrent()){execution.finish();return;}elapsed++;if(elapsed>=maximumTicks){impact();execution.finish();return;}warningVisual();execution.later(Duration.ofMillis(50),this::step);}
|
||||
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{execution.damage(target,amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
|
||||
void step(){
|
||||
if(closed||impacted)return;
|
||||
if(!execution.stillCurrent()){
|
||||
execution.resolve("interrupted",Map.of("stage","warning","warning_ticks_elapsed",elapsed));
|
||||
execution.finish();return;
|
||||
}
|
||||
elapsed++;
|
||||
if(elapsed>=maximumTicks){impact();execution.finish();return;}
|
||||
warningVisual();execution.later(Duration.ofMillis(50),this::step);
|
||||
}
|
||||
void impact(){
|
||||
if(closed||impacted)return;impacted=true;
|
||||
if(!groundCurrent()||!execution.stillCurrent()){
|
||||
execution.resolve("interrupted",Map.of("stage","warning","reason","ground_or_source_unavailable"));
|
||||
return;
|
||||
}
|
||||
impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));
|
||||
int attempted=0,accepted=0;var candidates=candidates(ground.origin(),captured.radius(),captured.verticalRange());
|
||||
for(var target:candidates){
|
||||
if(attempted==64||!execution.stillCurrent())break;
|
||||
if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;
|
||||
var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source)
|
||||
.withDirectEntity(source).withDamageLocation(ground.origin()).build();
|
||||
var attempt=new Attempt(execution,target,amount,attempted+1);damaging=attempt;
|
||||
try{execution.damage(target,amount,sourceDamage);}
|
||||
finally{if(damaging==attempt)damaging=null;}
|
||||
attempted++;if(attempt.accepted)accepted++;
|
||||
}
|
||||
var evidence=new LinkedHashMap<String,Object>();
|
||||
evidence.put("stage","impact");evidence.put("warning_ticks_elapsed",elapsed);
|
||||
evidence.put("impact_location",locationData(ground.origin()));
|
||||
evidence.put("candidate_count",candidates.size());evidence.put("attempted_count",attempted);
|
||||
evidence.put("accepted_count",accepted);evidence.put("requested_amount",amount);
|
||||
evidence.put("terminal_reason",!execution.stillCurrent()?"source_interrupted":accepted>0?"accepted_hits":"no_accepted_targets");
|
||||
execution.resolve(!execution.stillCurrent()?"interrupted":accepted>0?"hit":"miss",evidence);
|
||||
}
|
||||
boolean groundCurrent(){try{return ground.origin().getWorld()!=null&&ground.block().getWorld()==ground.origin().getWorld()&&ground.block().getType().isSolid();}catch(RuntimeException invalid){return false;}}
|
||||
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);particles.world(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
|
||||
void impactVisual(){try{var world=ground.origin().getWorld();particles.world(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);particles.world(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
|
||||
@@ -66,11 +106,39 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
|
||||
public static BigInteger damage(BigInteger intelligence){return damage(BigInteger.valueOf(14),intelligence);}
|
||||
static BigInteger damage(BigInteger base,BigInteger intelligence){Objects.requireNonNull(base);Objects.requireNonNull(intelligence);if(base.signum()<0||intelligence.signum()<0)throw new IllegalArgumentException("Invalid Meteor damage input");var delta=intelligence.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&÷d[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
|
||||
@Override public boolean accepts(ControlInput input){return !closed&&input.target()==null&&ground(input.player(),settings.targetRange())!=null;}
|
||||
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent())return;var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());if(target==null){execution.finish();return;}new Warning(execution,target,captured).start();}
|
||||
@Override public void activate(AbilityExecution execution){
|
||||
if(closed||!execution.stillCurrent())return;
|
||||
var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());
|
||||
if(target==null){execution.resolve("miss",Map.of("stage","targeting","reason","ground_unavailable"));execution.finish();return;}
|
||||
new Warning(execution,target,captured).start();
|
||||
}
|
||||
private static Ground ground(Player player,double range){try{var eye=player.getEyeLocation();var hit=player.getWorld().rayTraceBlocks(eye,eye.getDirection(),range,FluidCollisionMode.NEVER,true);if(hit==null||hit.getHitBlock()==null||hit.getHitBlockFace()!=BlockFace.UP||!hit.getHitBlock().getType().isSolid())return null;var block=hit.getHitBlock();return new Ground(block,block.getLocation().add(.5,1,.5));}catch(RuntimeException invalid){return null;}}
|
||||
private List<LivingEntity> candidates(Location origin,double radius,double vertical){List<Entity> nearby;try{nearby=new ArrayList<>(origin.getWorld().getNearbyEntities(origin,radius,vertical,radius));}catch(RuntimeException unavailable){return List.of();}var unique=new LinkedHashMap<UUID,LivingEntity>();int inspected=0;for(var entity:nearby){if(++inspected>256)break;if(entity instanceof LivingEntity living)unique.putIfAbsent(living.getUniqueId(),living);}var result=new ArrayList<>(unique.values());result.sort(Comparator.comparingDouble((LivingEntity target)->distance(origin,target)).thenComparing(LivingEntity::getUniqueId));return result;}
|
||||
private boolean eligible(AbilityExecution execution,LivingEntity target,Location origin,double radius,double vertical){var source=execution.input().player();if(target==source||!inside(origin,target,radius,vertical)||!unobstructed(origin,target))return false;if(!(target instanceof Player player))return true;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return false;var sourceParty=parties.partyOf(source.getUniqueId()).orElse(null);var targetParty=parties.partyOf(player.getUniqueId()).orElse(null);return sourceParty==null||targetParty==null||!sourceParty.id().equals(targetParty.id());}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void acceptedDamage(EntityDamageEvent event){var attempt=damaging;if(attempt==null||attempt.accepted||event.isCancelled()||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;attempt.accepted=true;attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)
|
||||
public void acceptedDamage(EntityDamageEvent event){
|
||||
var attempt=damaging;
|
||||
if(!ownsAcceptedDamage(event)||attempt.accepted||event.isCancelled()
|
||||
||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;
|
||||
attempt.accepted=true;
|
||||
attempt.execution.acceptedDamage(attempt.target,attempt.requested,event.getFinalDamage(),
|
||||
attempt.execution.input().player(),Map.of("damage_kind","owned_meteor_impact","ordinal",attempt.ordinal));
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
/** Exclude only this accepted in-flight impact from the generic native damage observer. */
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event){
|
||||
var attempt=damaging;
|
||||
if(attempt==null||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC)return false;
|
||||
var caster=attempt.execution.input().player();
|
||||
var causing=event.getDamageSource().getCausingEntity();var direct=event.getDamageSource().getDirectEntity();
|
||||
return (causing==null||causing==caster)&&(direct==null||direct==caster);
|
||||
}
|
||||
private static Map<String,Object> locationData(Location location){
|
||||
var world=location==null?null:location.getWorld();
|
||||
if(world==null||world.getUID()==null||world.getName()==null)return Map.of("location_status","unavailable");
|
||||
return Map.of("world_id",world.getUID().toString(),"world_name",world.getName(),
|
||||
"x",location.getX(),"y",location.getY(),"z",location.getZ());
|
||||
}
|
||||
private static boolean inside(Location origin,LivingEntity target,double radius,double vertical){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return false;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz<=radius*radius&&Math.abs(at.getY()-origin.getY())<=vertical;}catch(RuntimeException invalid){return false;}}
|
||||
private static boolean unobstructed(Location origin,LivingEntity target){try{var world=origin.getWorld();var at=target.getLocation();if(world==null||at.getWorld()!=world)return false;var bounds=target.getBoundingBox();var center=bounds==null?at.toVector().add(new org.bukkit.util.Vector(0,target.getHeight()/2,0)):bounds.getCenter();var offset=center.subtract(origin.toVector());double distance=offset.length();if(!Double.isFinite(distance)||distance==0)return distance==0;return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;}catch(RuntimeException invalid){return false;}}
|
||||
private static double distance(Location origin,LivingEntity target){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;}catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}}
|
||||
|
||||
@@ -41,7 +41,7 @@ public final class OutgoingDamageBonus implements Listener, AutoCloseable {
|
||||
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(recipient.getUniqueId()); }
|
||||
}
|
||||
}
|
||||
private record Used(Grant grant, Entity target, BigDecimal amount) { }
|
||||
private record Used(Grant grant, Entity target, BigDecimal amount, double base) { }
|
||||
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
@@ -82,20 +82,26 @@ public final class OutgoingDamageBonus implements Listener, AutoCloseable {
|
||||
BigDecimal added = BigDecimal.valueOf(original).multiply(strongest.percent.movePointLeft(2));
|
||||
double increased = BigDecimal.valueOf(original).add(added).doubleValue();
|
||||
if (!Double.isFinite(increased) || increased < 0 || added.signum() <= 0) return;
|
||||
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added));
|
||||
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added, original));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void accepted(EntityDamageEvent event) {
|
||||
var used = pending.remove(event);
|
||||
if (used == null || event.isCancelled() || event.getFinalDamage() <= 0 || !used.grant.valid()) return;
|
||||
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, used.amount);
|
||||
if (used == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())
|
||||
|| event.getFinalDamage() <= 0 || !used.grant.valid()) return;
|
||||
double effective = Math.min(used.amount.doubleValue(), event.getFinalDamage());
|
||||
if (!Double.isFinite(effective) || effective <= 0) return;
|
||||
var aided = sessions.active(used.grant.recipient.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
used.grant.execution.acceptedBoost(aided, used.grant.recipient, used.target,
|
||||
used.base, effective, event.getFinalDamage(), used.grant.percent);
|
||||
var actual = BigDecimal.valueOf(effective);
|
||||
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, actual);
|
||||
if (aided == null || aided.character().equals(used.grant.execution.input().session().character())) return;
|
||||
var sourceParty = parties.partyOf(used.grant.execution.input().player().getUniqueId()).orElse(null);
|
||||
var aidedParty = parties.partyOf(used.grant.recipient.getUniqueId()).orElse(null);
|
||||
if (sourceParty == null || aidedParty == null || !sourceParty.id().equals(aidedParty.id())) return;
|
||||
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, used.amount));
|
||||
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, actual));
|
||||
}
|
||||
|
||||
private static Entity attacker(EntityDamageByEntityEvent event) {
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -72,16 +72,22 @@ public final class Purify implements AbilityEffect {
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (!execution.stillCurrent()) return;
|
||||
var configured = from(execution.definition()); var target = target(execution.input(), configured.range());
|
||||
if (target == null) return;
|
||||
int removed = 0;
|
||||
if (target == null) { execution.resolve("miss"); return; }
|
||||
var removed = new java.util.ArrayList<String>();
|
||||
int attempted = 0;
|
||||
for (var type : HARMFUL) {
|
||||
if (!execution.stillCurrent() || !current(execution.input(), target, configured.range())) break;
|
||||
if (target.player().getPotionEffect(type) == null) continue;
|
||||
attempted++;
|
||||
target.player().removePotionEffect(type);
|
||||
if (target.player().getPotionEffect(type) == null) removed++;
|
||||
if (target.player().getPotionEffect(type) == null)
|
||||
removed.add(type.getKey().getKey().toUpperCase(java.util.Locale.ROOT));
|
||||
}
|
||||
if (removed == 0) return;
|
||||
var amount = BigDecimal.valueOf(removed);
|
||||
if (removed.isEmpty()) { execution.resolve(attempted == 0 ? "miss" : "cancelled"); return; }
|
||||
execution.acceptedCleanse(target.active(), target.player(), List.copyOf(removed));
|
||||
execution.resolve("cleansed", Map.of("target_player_id", target.player().getUniqueId().toString(),
|
||||
"removed_effects", List.copyOf(removed)));
|
||||
var amount = BigDecimal.valueOf(removed.size());
|
||||
execution.report(AbilityReport.Type.CLEANSING, target.player(), amount);
|
||||
if (target.player() != execution.input().player()
|
||||
&& execution.stillCurrent() && current(execution.input(), target, configured.range())) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
|
||||
private final class Area implements AutoCloseable {
|
||||
final long sequence; final AbilityExecution execution; final Player source; final Settings settings;
|
||||
final Map<UUID, Benefit> recipients = new java.util.HashMap<>(); boolean closed;
|
||||
final Map<UUID, Benefit> recipients = new java.util.HashMap<>();
|
||||
UUID application; String endReason = "interrupted"; boolean closed;
|
||||
Area(AbilityExecution execution, Settings settings) {
|
||||
sequence = ++sequences; this.execution = execution; source = execution.input().player(); this.settings = settings;
|
||||
}
|
||||
@@ -86,19 +87,33 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; areas.remove(sequence, this);
|
||||
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
|
||||
if (application != null) execution.effectEnded(application, endReason);
|
||||
}
|
||||
}
|
||||
|
||||
private final class Benefit implements AutoCloseable {
|
||||
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus; boolean closed;
|
||||
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus;
|
||||
final UUID application; boolean closed;
|
||||
Benefit(Area area, Player player) {
|
||||
this.area = area; this.player = player;
|
||||
speed = speed(player, area.settings.speedAmplifier());
|
||||
bonus = bonuses.grant(area.execution, player, area.settings.damageBonusPercent(), () -> !closed && area.eligible(player));
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("speed_amplifier", area.settings.speedAmplifier());
|
||||
evidence.put("damage_bonus_percent", area.settings.damageBonusPercent().toPlainString());
|
||||
evidence.put("speed_status", "owned_or_superseded_by_existing_speed");
|
||||
if (active != null) {
|
||||
evidence.put("recipient_character_id", active.character().toString());
|
||||
evidence.put("recipient_session_id", active.session().toString());
|
||||
}
|
||||
application = area.execution.effectApplied("rallying_cry_benefit", player, evidence);
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
|
||||
closeResource(bonus); closeResource(speed);
|
||||
if (application != null) area.execution.effectEnded(application,
|
||||
area.closed ? area.endReason : "recipient_eligibility_changed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,14 +202,23 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var area = new Area(execution, from(execution.definition())); areas.put(area.sequence, area);
|
||||
execution.own(area); area.refresh(); execution.later(area.settings.duration(), execution::finish);
|
||||
execution.own(area);
|
||||
area.application = execution.effectApplied("rallying_cry_area", null,
|
||||
Map.of("radius", Double.toString(area.settings.radius()),
|
||||
"duration_seconds", seconds(area.settings.duration())));
|
||||
area.refresh(); execution.later(area.settings.duration(), () -> {
|
||||
area.endReason = "expired"; execution.resolve("expired"); execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
private AutoCloseable speed(Player player, int amplifier) {
|
||||
var state = speeds.computeIfAbsent(player.getUniqueId(), ignored -> new SpeedState(player));
|
||||
var grant = new SpeedGrant(state, amplifier); state.grants.add(grant); state.refresh(); return grant;
|
||||
}
|
||||
private void breakArea(Area area) { area.close(); area.execution.finish(); }
|
||||
private void breakArea(Area area) {
|
||||
area.endReason = "eligibility_changed"; area.close();
|
||||
area.execution.resolve("interrupted"); area.execution.finish();
|
||||
}
|
||||
private static boolean inside(Player source, Player target, double radius) {
|
||||
try { var a = source.getLocation(); var b = target.getLocation();
|
||||
return a.getWorld() == b.getWorld() && a.distanceSquared(b) <= radius * radius;
|
||||
@@ -220,7 +244,10 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; if (sweepTask != null) sweepTask.cancel();
|
||||
for (var area : List.copyOf(areas.values())) area.execution.finish(); areas.clear();
|
||||
for (var area : List.copyOf(areas.values())) {
|
||||
area.endReason = "shutdown"; area.execution.resolve("interrupted"); area.execution.finish();
|
||||
}
|
||||
areas.clear();
|
||||
for (var state : List.copyOf(speeds.values())) { state.grants.clear(); state.refresh(); }
|
||||
speeds.clear();
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private games.dmg.fantasystick.session.DownedController downed;
|
||||
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
|
||||
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
|
||||
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
|
||||
private java.util.function.Supplier<Duration> downedProtection=()->Duration.ofSeconds(5);
|
||||
private final Map<Inventory, View> inventories = new IdentityHashMap<>();
|
||||
private final Map<UUID, View> targets = new LinkedHashMap<>();
|
||||
@@ -62,16 +64,22 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
final Player target;
|
||||
final Inventory inventory;
|
||||
final Map<Integer, Choice> choices;
|
||||
final games.dmg.fantasystick.events.ReviveEventBridge.Cast cast;
|
||||
boolean selecting;
|
||||
boolean eventResolved;
|
||||
boolean finished;
|
||||
View(AbilityExecution execution, Player target, Inventory inventory, Map<Integer, Choice> choices) {
|
||||
this.execution = execution; this.target = target; this.inventory = inventory; this.choices = choices;
|
||||
View(AbilityExecution execution, Player target, Inventory inventory, Map<Integer, Choice> choices,games.dmg.fantasystick.events.ReviveEventBridge.Cast cast) {
|
||||
this.execution = execution; this.target = target; this.inventory = inventory; this.choices = choices;this.cast=cast;
|
||||
}
|
||||
void resolve(String outcome,String stage){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.resolved(cast,outcome,stage);}}
|
||||
void fail(String reason){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.failed(cast,reason);}}
|
||||
void revived(RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.revived(cast,reservation,dead,restored,target);}}
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
inventories.remove(inventory, this); targets.remove(target.getUniqueId(), this);
|
||||
inventory.clear();
|
||||
if(!selecting)resolve("cancelled_or_expired","tail_selection_closed");
|
||||
try { target.closeInventory(); } catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
}
|
||||
@@ -85,6 +93,12 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
|
||||
public void attachDowned(games.dmg.fantasystick.session.DownedController controller,java.util.function.Supplier<Duration> protection){if(closed||downed!=null)throw new IllegalStateException("Downed Revive already attached or closed");downed=Objects.requireNonNull(controller);downedProtection=Objects.requireNonNull(protection);}
|
||||
public void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(closed||downedEvents!=null)throw new IllegalStateException("Downed Revive events already attached or closed");downedEvents=Objects.requireNonNull(events);}
|
||||
public void attachEvents(games.dmg.fantasystick.events.ReviveEventBridge events){if(closed||reviveEvents!=null)throw new IllegalStateException("Revive events already attached or closed");reviveEvents=Objects.requireNonNull(events);}
|
||||
public void attachAdminRevivalEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events){if(closed||adminRevivalEvents!=null)throw new IllegalStateException("Admin revival events already attached or closed");adminRevivalEvents=Objects.requireNonNull(events);}
|
||||
private void recoveryEvent(RevivalStore.Reservation reservation,CharacterRecord restored,Player target){
|
||||
if(reservation.administrative()){if(adminRevivalEvents!=null)adminRevivalEvents.recovered(reservation,restored,target);}
|
||||
else if(reviveEvents!=null)reviveEvents.recovered(reservation,restored,target);
|
||||
}
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
return new AbilityDefinition("revive", "Revive", Material.ECHO_SHARD,
|
||||
@@ -107,15 +121,16 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
execution.finish(); return;
|
||||
}
|
||||
if(downed!=null&&downed.downed(target)){var episode=downedEpisode(target);if(episode==null){execution.finish();return;}var action=UUID.randomUUID();var caster=execution.character();var cooldown=caster.cooldowns().get("revive");var distance=execution.input().player().getLocation().distance(target.getLocation());var castAt=clock.instant();var decision=new games.dmg.fantasystick.session.DownedEpisode.Decision("revive",execution.input().player().getUniqueId(),execution.input().player().getName(),caster.id(),caster.name(),execution.input().session().session(),action,castAt,cooldown,distance,true);if(downedEvents!=null)downedEvents.abilityCast(episode,decision);downed.revive(target,downedProtection.get(),decision,status->{if(downedEvents!=null&&status!=games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED&&status!=games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED_PENDING_RECOVERY)downedEvents.abilityResolved(episode,decision,status.name().toLowerCase(java.util.Locale.ROOT),clock.instant());execution.input().player().sendMessage(switch(status){case APPLIED->"Revive restored the downed character in place.";case APPLIED_PENDING_RECOVERY->"Revive restored the downed character; durable publication is pending recovery.";case EXPIRED->"Revive arrived after the downed countdown expired.";case STALE->"That downed episode was already resolved or changed.";case FAILED->"Downed Revive is pending recovery; no second rescue was attempted.";});execution.finish();});return;}
|
||||
var cast=reviveEvents==null?null:reviveEvents.cast(execution,target);
|
||||
var captured = candidates(target);
|
||||
if (captured.isEmpty()) {
|
||||
execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive.");
|
||||
if(cast!=null)reviveEvents.resolved(cast,"no_eligible_tail","tail_scan");execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive.");
|
||||
execution.finish(); return;
|
||||
}
|
||||
var loads = captured.stream().map(candidate -> characters.find(candidate.identity().characterId())).toList();
|
||||
CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); execution.input().player().sendMessage("Revive could not inspect those Tails safely."); execution.finish(); return; }
|
||||
if (closed || !execution.stillCurrent() || !target.isOnline()) { execution.finish(); return; }
|
||||
if (failure != null) { if(cast!=null)reviveEvents.failed(cast,"tail_inspection_failed");report(failure); execution.input().player().sendMessage("Revive could not inspect those Tails safely."); execution.finish(); return; }
|
||||
if (closed || !execution.stillCurrent() || !target.isOnline()) {if(cast!=null)reviveEvents.resolved(cast,"stale","tail_inspection"); execution.finish(); return; }
|
||||
var eligible = new ArrayList<Choice>();
|
||||
Instant now = clock.instant();
|
||||
for (int index = 0; index < captured.size(); index++) {
|
||||
@@ -126,10 +141,10 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
if (eligible(record, candidate.identity(), now) && unchanged(target, candidate)) eligible.add(new Choice(candidate, record));
|
||||
}
|
||||
if (eligible.isEmpty()) {
|
||||
execution.input().player().sendMessage("That player has no current Tail younger than 24 hours.");
|
||||
if(cast!=null)reviveEvents.resolved(cast,"expired_or_stale","tail_eligibility");execution.input().player().sendMessage("That player has no current Tail younger than 24 hours.");
|
||||
execution.finish(); return;
|
||||
}
|
||||
open(execution, target, eligible, now);
|
||||
open(execution, target, eligible, now,cast);
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -174,7 +189,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
return current != null && current.equals(candidate.captured());
|
||||
}
|
||||
|
||||
private void open(AbilityExecution execution, Player target, List<Choice> eligible, Instant now) {
|
||||
private void open(AbilityExecution execution, Player target, List<Choice> eligible, Instant now,games.dmg.fantasystick.events.ReviveEventBridge.Cast cast) {
|
||||
View old = targets.get(target.getUniqueId()); if (old != null) old.execution.finish();
|
||||
int size = Math.min(54, Math.max(9, ((eligible.size() + 8) / 9) * 9));
|
||||
Inventory inventory = popups.create(size, Component.text("Choose a Tail to Revive"));
|
||||
@@ -187,7 +202,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
Component.text("Eligible for " + seconds + " more seconds"), Component.text("Click to continue this character")));
|
||||
display.setItemMeta(meta); inventory.setItem(index, display); choices.put(index, choice);
|
||||
}
|
||||
var view = new View(execution, target, inventory, Map.copyOf(choices));
|
||||
var view = new View(execution, target, inventory, Map.copyOf(choices),cast);
|
||||
inventories.put(inventory, view); targets.put(target.getUniqueId(), view); execution.own(view);
|
||||
target.openInventory(inventory);
|
||||
execution.later(VIEW_TIME, execution::finish);
|
||||
@@ -231,14 +246,14 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
|
||||
private void confirm(View view, Choice choice) {
|
||||
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || !unchanged(view.target, choice.candidate())) {
|
||||
view.execution.finish(); return;
|
||||
view.resolve("stale","tail_confirmation");view.execution.finish(); return;
|
||||
}
|
||||
characters.find(choice.record().id()).whenComplete((found, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive could not recheck that Tail; no item changed."); view.execution.finish(); return; }
|
||||
if (failure != null) {view.fail("tail_recheck_failed"); report(failure); view.execution.input().player().sendMessage("Revive could not recheck that Tail; no item changed."); view.execution.finish(); return; }
|
||||
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || found.isEmpty()
|
||||
|| !eligible(found.orElseThrow(), choice.candidate().identity(), clock.instant())
|
||||
|| !unchanged(view.target, choice.candidate())) {
|
||||
view.execution.input().player().sendMessage("That Tail is no longer eligible; no item changed.");
|
||||
view.resolve("expired_or_stale","tail_confirmation");view.execution.input().player().sendMessage("That Tail is no longer eligible; no item changed.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
reserve(view, choice.candidate(), found.orElseThrow());
|
||||
@@ -249,17 +264,17 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
ItemStack source = view.target.getInventory().getItem(candidate.slot());
|
||||
int destination = source.getAmount() == 1 ? -1 : emptyDirectSlot(view.target, candidate.slot());
|
||||
if (source.getAmount() > 1 && destination < 0) {
|
||||
view.target.sendMessage("Revive needs one empty direct inventory slot to preserve the copied Tails.");
|
||||
view.resolve("unsafe_capacity","tail_reservation");view.target.sendMessage("Revive needs one empty direct inventory slot to preserve the copied Tails.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
var reservation = new RevivalStore.Reservation(UUID.randomUUID(), view.execution.character().id(),
|
||||
view.target.getUniqueId(), dead.id(), dead.deathId(), clock.instant().truncatedTo(ChronoUnit.SECONDS),
|
||||
candidate.slot(), source.getAmount(), destination);
|
||||
reservations.reserve(reservation).whenComplete((reserved, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; }
|
||||
if (failure != null) {view.fail("reservation_save_failed"); report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; }
|
||||
if (reserved.isEmpty() || closed || !view.execution.stillCurrent() || !view.target.isOnline()
|
||||
|| !unchanged(view.target, candidate)) {
|
||||
view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed.");
|
||||
view.resolve("stale","tail_reservation");view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
deliver(view, dead, reservation);
|
||||
@@ -290,22 +305,22 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
}
|
||||
view.target.updateInventory();
|
||||
} catch (RuntimeException uncertain) {
|
||||
report(uncertain); view.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery.");
|
||||
view.fail("native_tail_delivery_uncertain");report(uncertain); view.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
|
||||
characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit)
|
||||
.whenComplete((restored, failure) -> schedule(() -> {
|
||||
if (failure != null || restored.isEmpty()) {
|
||||
if (failure != null) report(failure);
|
||||
view.fail(failure!=null?"character_restore_failed":"character_restore_unavailable");if (failure != null) report(failure);
|
||||
view.target.sendMessage("The Tail changed, but character restoration is pending recovery; do not move the sheet.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
if (installFinalSheet(view.target, restored.orElseThrow())) {
|
||||
reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; });
|
||||
view.revived(reservation,dead,restored.orElseThrow());reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; });
|
||||
view.target.sendMessage("Restored character sheet: " + restored.orElseThrow().name() + ".");
|
||||
view.execution.input().player().sendMessage("Revive restored " + restored.orElseThrow().name() + ".");
|
||||
} else view.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");
|
||||
} else {view.fail("restored_but_sheet_custody_ambiguous");view.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");}
|
||||
view.execution.finish();
|
||||
}));
|
||||
}
|
||||
@@ -349,7 +364,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
: source != null && source.getAmount() == reservation.sourceAmount() - 1
|
||||
&& tails.identify(source).filter(id -> id.deathId().equals(reservation.death())).isPresent();
|
||||
if (!record.dead() && hasSheet && installFinalSheet(target, record)) {
|
||||
reservations.clear(reservation.character(), reservation.id()); return;
|
||||
recoveryEvent(reservation,record,target);reservations.clear(reservation.character(), reservation.id()); return;
|
||||
}
|
||||
if (record.dead() && unchangedTail && !hasSheet) {
|
||||
reservations.clear(reservation.character(), reservation.id()); return;
|
||||
@@ -366,7 +381,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
.whenComplete((restored, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) {
|
||||
reservations.clear(reservation.character(), reservation.id());
|
||||
recoveryEvent(reservation,restored.orElseThrow(),target);reservations.clear(reservation.character(), reservation.id());
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -53,23 +53,30 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final AbilityExecution execution; final Player source;
|
||||
final Location origin; final Settings captured; final Map<UUID, AutoCloseable> mitigation = new LinkedHashMap<>();
|
||||
final Location origin; final Settings captured; final Map<UUID, Benefit> mitigation = new LinkedHashMap<>();
|
||||
Duration elapsed = Duration.ZERO; Duration nextPulse; final Duration scanPeriod;
|
||||
int pending; boolean expired; boolean closed;
|
||||
UUID areaApplication; int pulseOrdinal;
|
||||
String endReason = "interrupted"; int pending; boolean expired; boolean closed;
|
||||
State(AbilityExecution execution, Settings captured) {
|
||||
this.execution = execution; this.source = execution.input().player(); this.origin = source.getLocation().clone();
|
||||
this.captured = captured; this.nextPulse = captured.pulseInterval();
|
||||
long period = Math.max(50_000_000L, (captured.duration().toNanos() + 59) / 60);
|
||||
this.scanPeriod = Duration.ofNanos(period);
|
||||
}
|
||||
void start() { states.add(this); execution.own(this); reconcile(); visual(); schedule(); }
|
||||
void start() {
|
||||
states.add(this); execution.own(this);
|
||||
areaApplication = execution.effectApplied("sanctuary_area", null,
|
||||
Map.of("radius", captured.radius(), "vertical_range", captured.verticalRange(),
|
||||
"duration_seconds", seconds(captured.duration()), "area_location", locationData(origin)));
|
||||
reconcile(); visual(); schedule();
|
||||
}
|
||||
void schedule() {
|
||||
if (closed || expired) return;
|
||||
Duration remaining = captured.duration().minus(elapsed);
|
||||
execution.later(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod, this::step);
|
||||
}
|
||||
void step() {
|
||||
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
|
||||
if (closed || !execution.stillCurrent()) { execution.resolve("interrupted"); execution.finish(); return; }
|
||||
Duration remaining = captured.duration().minus(elapsed);
|
||||
elapsed = elapsed.plus(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod);
|
||||
reconcile(); visual();
|
||||
@@ -77,20 +84,22 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
pulse(); nextPulse = nextPulse.plus(captured.pulseInterval());
|
||||
}
|
||||
if (elapsed.compareTo(captured.duration()) >= 0) {
|
||||
expired = true; closeMitigation(); if (pending == 0) execution.finish();
|
||||
expired = true; endReason = "expired"; closeMitigation();
|
||||
execution.resolve("expired", Map.of("pulse_count", pulseOrdinal));
|
||||
if (pending == 0) execution.finish();
|
||||
} else schedule();
|
||||
}
|
||||
void reconcile() {
|
||||
var eligible = recipients();
|
||||
for (var entry : List.copyOf(mitigation.entrySet())) {
|
||||
if (eligible.stream().noneMatch(player -> player.getUniqueId().equals(entry.getKey()))) {
|
||||
try { entry.getValue().close(); } catch (Exception ignored) { } mitigation.remove(entry.getKey());
|
||||
entry.getValue().close(); mitigation.remove(entry.getKey());
|
||||
}
|
||||
}
|
||||
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored ->
|
||||
combatMitigation.grant(execution, player, captured.damageReductionPercent(), () -> recipientCurrent(player)));
|
||||
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored -> new Benefit(this, player));
|
||||
}
|
||||
void pulse() {
|
||||
pulseOrdinal++;
|
||||
for (var player : recipients()) if (owner(player) == this) heal(player);
|
||||
}
|
||||
void heal(Player player) {
|
||||
@@ -141,11 +150,46 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
captured.radius() / 2, 0.1, captured.radius() / 2, 0); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
void closeMitigation() {
|
||||
for (var grant : List.copyOf(mitigation.values())) try { grant.close(); } catch (Exception ignored) { }
|
||||
for (var grant : List.copyOf(mitigation.values())) grant.close();
|
||||
mitigation.clear();
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; expired = true; states.remove(this); closeMitigation();
|
||||
if (areaApplication != null) execution.effectEnded(areaApplication, endReason);
|
||||
}
|
||||
}
|
||||
|
||||
private final class Benefit implements AutoCloseable {
|
||||
final State state; final Player player; final AutoCloseable grant;
|
||||
UUID application; boolean closed;
|
||||
Benefit(State state, Player player) {
|
||||
this.state = state; this.player = player;
|
||||
grant = combatMitigation.grant(state.execution, player, state.captured.damageReductionPercent(),
|
||||
() -> !closed && state.recipientCurrent(player), (event, amount) -> {
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var attacker = event.getDamageSource().getCausingEntity();
|
||||
if (attacker == null && event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|
||||
attacker = byEntity.getDamager();
|
||||
if (active != null && attacker != null && amount > 0)
|
||||
state.execution.acceptedPrevention(active, player, attacker,
|
||||
event.getDamage() + amount, amount, event.getFinalDamage(), application);
|
||||
});
|
||||
try {
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("damage_reduction_percent", state.captured.damageReductionPercent().toPlainString());
|
||||
if (active != null) {
|
||||
evidence.put("recipient_character_id", active.character().toString());
|
||||
evidence.put("recipient_session_id", active.session().toString());
|
||||
}
|
||||
application = state.execution.effectApplied("sanctuary_benefit", player, evidence);
|
||||
} catch (RuntimeException ignored) { /* Auditing cannot refuse a native mitigation grant. */ }
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
try { grant.close(); } catch (Exception ignored) { }
|
||||
if (application != null) state.execution.effectEnded(application,
|
||||
state.closed || state.expired ? state.endReason : "recipient_eligibility_changed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,10 +253,15 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
double desired = Math.min(maximum, persisted.health().doubleValue()); double restored = Math.min(accepted, desired - current);
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
|
||||
target.player().setHealth(Math.min(maximum, current + restored));
|
||||
double after = target.player().getHealth();
|
||||
double actual = after - current;
|
||||
if (!Double.isFinite(actual) || actual <= 0) return;
|
||||
state.execution.acceptedHeal(target.active(), target.player(), persisted,
|
||||
accepted, actual, current, after, maximum);
|
||||
state.execution.report(AbilityReport.Type.HEALING, target.player(),
|
||||
BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
BigDecimal.valueOf(actual).stripTrailingZeros());
|
||||
if (target.player() != state.source) supports.accept(new MendingLight.Support(
|
||||
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(restored).stripTrailingZeros()));
|
||||
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(actual).stripTrailingZeros()));
|
||||
} finally { completePending(state); }
|
||||
}
|
||||
private static void completePending(State state) {
|
||||
@@ -240,6 +289,13 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance, org.bukkit.FluidCollisionMode.NEVER, true) == null;
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
var world = location == null ? null : location.getWorld();
|
||||
if (world == null || world.getUID() == null || world.getName() == null)
|
||||
return Map.of("location_status", "unavailable");
|
||||
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
|
||||
"x", location.getX(), "y", location.getY(), "z", location.getZ());
|
||||
}
|
||||
private static double distance(Location origin, Player player) {
|
||||
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return Double.POSITIVE_INFINITY;
|
||||
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ(); return dx * dx + dz * dz;
|
||||
|
||||
@@ -80,17 +80,24 @@ public final class SecondWind implements AbilityEffect {
|
||||
BigInteger base = definition.parameters().get("base-healing").toBigIntegerExact();
|
||||
var player = execution.input().player();
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) { execution.finish(); return; }
|
||||
if (attribute == null) { execution.resolve("cancelled", Map.of("reason", "maximum_unavailable")); execution.finish(); return; }
|
||||
double current = player.getHealth(); double maximum = attribute.getValue();
|
||||
double missing = maximum - current;
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) {
|
||||
execution.resolve("cancelled", Map.of("reason", "health_unavailable_or_full")); execution.finish(); return;
|
||||
}
|
||||
BigInteger exact = healing(base, execution.character().stats().get(Stat.WIS));
|
||||
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
|
||||
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
|
||||
if (!Double.isFinite(proposed) || proposed <= 0) {
|
||||
execution.resolve("cancelled", Map.of("reason", "healing_nonpositive")); execution.finish(); return;
|
||||
}
|
||||
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event);
|
||||
double accepted = Math.min(missing, event.getAmount());
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {
|
||||
execution.resolve("cancelled", Map.of("reason", "native_healing_cancelled_or_nonpositive"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
var record = execution.character(); var expected = record.revision();
|
||||
BigDecimal target = BigDecimal.valueOf(current + accepted);
|
||||
BigDecimal limit = nativeLimit.get();
|
||||
@@ -99,18 +106,33 @@ public final class SecondWind implements AbilityEffect {
|
||||
nextTick.accept(() -> {
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty() || !execution.stillCurrent()) {
|
||||
execution.resolve(failure != null || saved == null || saved.isEmpty() ? "failed" : "interrupted",
|
||||
Map.of("reason", failure != null ? "health_save_failed" : saved == null || saved.isEmpty()
|
||||
? "health_save_unconfirmed" : "session_changed_before_healing"));
|
||||
player.sendMessage("Second Wind health could not be saved; no healing was applied."); return;
|
||||
}
|
||||
var persisted = saved.orElseThrow();
|
||||
if (!sessions.acknowledgeProgress(execution.input().session(), expected, persisted)) {
|
||||
execution.resolve("interrupted", Map.of("reason", "health_saved_session_changed"));
|
||||
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
|
||||
}
|
||||
double nativeBefore = player.getHealth();
|
||||
double publish = Math.min(attribute.getValue(), Math.max(nativeBefore, persisted.health().doubleValue()));
|
||||
player.setHealth(publish);
|
||||
double restored = publish - nativeBefore;
|
||||
if (Double.isFinite(restored) && restored > 0) execution.report(AbilityReport.Type.HEALING,
|
||||
player, BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
try { player.setHealth(publish); }
|
||||
catch (RuntimeException nativeFailure) {
|
||||
execution.resolve("failed", Map.of("reason", "native_health_publication_failed"));
|
||||
throw nativeFailure;
|
||||
}
|
||||
double nativeAfter = player.getHealth();
|
||||
double restored = nativeAfter - nativeBefore;
|
||||
if (Double.isFinite(restored) && restored > 0) {
|
||||
try { execution.acceptedHeal(execution.input().session(), player, persisted, proposed,
|
||||
restored, nativeBefore, nativeAfter, maximum); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot change an applied native heal. */ }
|
||||
execution.resolve("healed", Map.of("reason", "native_self_heal_applied"));
|
||||
execution.report(AbilityReport.Type.HEALING, player,
|
||||
BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
} else execution.resolve("cancelled", Map.of("reason", "native_heal_not_observed"));
|
||||
} finally { execution.finish(); }
|
||||
});
|
||||
} catch (RuntimeException rejected) { execution.finish(); }
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerVelocityEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
@@ -39,9 +40,12 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; final Settings settings; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, Settings settings) {
|
||||
final AbilityExecution execution; final LivingEntity target; final Settings settings;
|
||||
final double requested; final double healthBefore;
|
||||
double finalDamage; boolean accepted; boolean cancelled;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, Settings settings, double requested, double healthBefore) {
|
||||
this.execution = execution; this.target = target; this.settings = settings;
|
||||
this.requested = requested; this.healthBefore = healthBefore;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,42 +89,109 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
|
||||
var current = from(execution.definition()); var player = execution.input().player();
|
||||
if (!inRange(player, target, current.range())) { execution.finish(); return; }
|
||||
if (!inRange(player, target, current.range())) {
|
||||
execution.resolve("miss", Map.of("reason", "target_out_of_range_or_obstructed"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
var exact = damage(current.baseDamage(), execution.character().stats().get(Stat.STR));
|
||||
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
|
||||
if (!Double.isFinite(amount) || amount <= 0) { execution.finish(); return; }
|
||||
if (!Double.isFinite(amount) || amount <= 0) {
|
||||
execution.resolve("miss", Map.of("reason", "damage_nonpositive")); execution.finish(); return;
|
||||
}
|
||||
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player).withDirectEntity(player).build();
|
||||
var attempt = new Attempt(execution, target, current); damaging = attempt;
|
||||
try { execution.damage(target, amount, source); }
|
||||
finally { if (damaging == attempt) damaging = null; execution.finish(); }
|
||||
double beforeHealth;
|
||||
try { beforeHealth = target.getHealth(); }
|
||||
catch (RuntimeException unavailable) { beforeHealth = Double.NaN; }
|
||||
var attempt = new Attempt(execution, target, current, amount, beforeHealth); damaging = attempt;
|
||||
boolean nativeFailed = false;
|
||||
try {
|
||||
execution.damage(target, amount, source);
|
||||
if (attempt.accepted) publishAccepted(attempt);
|
||||
}
|
||||
catch (RuntimeException failed) {
|
||||
nativeFailed = true;
|
||||
if (!attempt.accepted) execution.resolve("failed", Map.of("reason", "native_damage_failed"));
|
||||
throw failed;
|
||||
} finally {
|
||||
if (damaging == attempt) damaging = null;
|
||||
if (!attempt.accepted && !nativeFailed)
|
||||
execution.resolve(attempt.cancelled ? "cancelled" : "miss",
|
||||
Map.of("reason", attempt.cancelled ? "native_damage_cancelled" : "native_damage_not_accepted"));
|
||||
execution.finish();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
/** Yield generic incoming-player capture to the owning accepted Shield Bash strike. */
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
return event instanceof EntityDamageByEntityEvent by && matches(damaging, by);
|
||||
}
|
||||
|
||||
private boolean matches(Attempt attempt, EntityDamageByEntityEvent event) {
|
||||
if (closed || attempt == null || event.getDamager() != attempt.execution.input().player()
|
||||
|| event.getEntity() != attempt.target) return false;
|
||||
var source = event.getDamageSource();
|
||||
return (source.getCausingEntity() == null || source.getCausingEntity() == attempt.execution.input().player())
|
||||
&& (source.getDirectEntity() == null || source.getDirectEntity() == attempt.execution.input().player());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = damaging;
|
||||
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.execution.input().player()
|
||||
|| event.getEntity() != attempt.target || !attempt.execution.stillCurrent()) return;
|
||||
if (!matches(attempt, event) || attempt.accepted || !attempt.execution.stillCurrent()) return;
|
||||
if (event.isCancelled()) { attempt.cancelled = true; return; }
|
||||
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
|
||||
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
|
||||
knockback(attempt);
|
||||
attempt.finalDamage = event.getFinalDamage();
|
||||
}
|
||||
|
||||
private void knockback(Attempt attempt) {
|
||||
var player = attempt.execution.input().player(); var target = attempt.target;
|
||||
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
|
||||
if (!finite(away) || away.lengthSquared() < 1.0e-12) return;
|
||||
Vector current = target.getVelocity();
|
||||
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
|
||||
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
|
||||
if (!finite(proposed)) return;
|
||||
if (target instanceof Player targetPlayer) {
|
||||
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
|
||||
if (event.isCancelled() || !finite(event.getVelocity())) return;
|
||||
proposed = event.getVelocity().clone();
|
||||
}
|
||||
target.setVelocity(proposed);
|
||||
private void publishAccepted(Attempt attempt) {
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("stage", "native_damage_accepted");
|
||||
try {
|
||||
double afterHealth = attempt.target.getHealth();
|
||||
if (Double.isFinite(attempt.healthBefore) && attempt.healthBefore > 0
|
||||
&& Double.isFinite(afterHealth) && afterHealth >= 0) {
|
||||
evidence.put("target_health_before", BigDecimal.valueOf(attempt.healthBefore).stripTrailingZeros().toPlainString());
|
||||
evidence.put("target_health_after", BigDecimal.valueOf(afterHealth).stripTrailingZeros().toPlainString());
|
||||
}
|
||||
} catch (RuntimeException ignored) { /* Target health may be unavailable after a native hit. */ }
|
||||
try { attempt.execution.acceptedDamage(attempt.target, attempt.requested, attempt.finalDamage,
|
||||
attempt.execution.input().player(), evidence); }
|
||||
catch (RuntimeException ignored) { /* File auditing cannot interrupt accepted native damage. */ }
|
||||
try { attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target,
|
||||
BigDecimal.valueOf(attempt.finalDamage).stripTrailingZeros()); }
|
||||
catch (RuntimeException ignored) { /* Feedback cannot alter accepted damage or knockback. */ }
|
||||
String knockbackStatus = knockback(attempt);
|
||||
attempt.execution.resolve("hit", Map.of("reason", "native_damage_accepted",
|
||||
"knockback_status", knockbackStatus));
|
||||
}
|
||||
|
||||
private String knockback(Attempt attempt) {
|
||||
if (!attempt.execution.stillCurrent()) return "session_changed_after_damage";
|
||||
try {
|
||||
var player = attempt.execution.input().player(); var target = attempt.target;
|
||||
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
|
||||
if (!finite(away) || away.lengthSquared() < 1.0e-12) return "direction_unavailable";
|
||||
Vector current = target.getVelocity();
|
||||
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
|
||||
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
|
||||
if (!finite(proposed)) return "velocity_invalid";
|
||||
if (target instanceof Player targetPlayer) {
|
||||
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
|
||||
if (event.isCancelled()) return "native_velocity_cancelled";
|
||||
if (!finite(event.getVelocity())) return "velocity_invalid";
|
||||
proposed = event.getVelocity().clone();
|
||||
}
|
||||
target.setVelocity(proposed);
|
||||
try { attempt.execution.effectApplied("shield_bash_knockback", target,
|
||||
Map.of("knockback_horizontal", decimal(attempt.settings.horizontalKnockback()),
|
||||
"accepted_velocity_x", decimal(proposed.getX()),
|
||||
"accepted_velocity_y", decimal(proposed.getY()),
|
||||
"accepted_velocity_z", decimal(proposed.getZ()),
|
||||
"status", "native_velocity_accepted")); }
|
||||
catch (RuntimeException ignored) { /* Accepted movement does not depend on auditing. */ }
|
||||
return "native_velocity_accepted";
|
||||
} catch (RuntimeException invalid) { return "native_velocity_failed"; }
|
||||
}
|
||||
|
||||
private static boolean inRange(Player player, LivingEntity target, double range) {
|
||||
|
||||
@@ -62,11 +62,24 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = from(execution.definition()); var player = execution.input().player();
|
||||
var grant = mitigation.grant(execution, player, current.reductionPercent());
|
||||
var grant = mitigation.grantAccepted(execution, player, current.reductionPercent(), () -> true,
|
||||
accepted -> {
|
||||
try {
|
||||
execution.acceptedPrevention(execution.input().session(), player, accepted.event().getDamager(),
|
||||
accepted.incoming(), accepted.prevented(), accepted.remaining(), null);
|
||||
} catch (RuntimeException ignored) { /* Auditing cannot change an accepted native hit. */ }
|
||||
});
|
||||
var state = new State(execution, player, grant);
|
||||
var previous = protections.put(player.getUniqueId(), state);
|
||||
if (previous != null) previous.execution.finish();
|
||||
execution.own(state); execution.later(current.duration(), execution::finish);
|
||||
if (previous != null) { previous.execution.resolve("replaced"); previous.execution.finish(); }
|
||||
execution.own(state);
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("reduction_percent", current.reductionPercent().stripTrailingZeros().toPlainString());
|
||||
evidence.put("duration_seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString());
|
||||
var activatedAt = execution.now();
|
||||
if (activatedAt != null) evidence.put("effect_until", activatedAt.plus(current.duration()).toString());
|
||||
execution.effectApplied("stone_skin", player, evidence);
|
||||
execution.later(current.duration(), () -> { execution.resolve("expired"); execution.finish(); });
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
@@ -78,7 +91,9 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
for (var state : List.copyOf(protections.values())) state.execution.finish();
|
||||
for (var state : List.copyOf(protections.values())) {
|
||||
state.execution.resolve("interrupted"); state.execution.finish();
|
||||
}
|
||||
protections.clear();
|
||||
}
|
||||
}
|
||||
@@ -13,21 +13,83 @@ import org.bukkit.projectiles.ProjectileSource;
|
||||
/** Plugin-owned temporary-health ledger layered over unrelated native absorption. */
|
||||
public final class TemporaryHealth implements Listener,AutoCloseable{
|
||||
public record Support(UUID target,UUID supportingCharacter,EquipmentSessions.Active aided,BigDecimal amount){}
|
||||
private final class Grant implements AutoCloseable{final long sequence;final AbilityExecution execution;final Player target;double remaining;boolean closed;
|
||||
Grant(AbilityExecution e,Player t,double amount){sequence=++sequences;execution=e;target=t;remaining=amount;}
|
||||
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));}}
|
||||
public record Accepted(Player target,Entity attacker,double incoming,double before,double after,
|
||||
double consumed,double remaining,org.bukkit.Location targetAtHit,
|
||||
org.bukkit.Location attackerAtHit){}
|
||||
private final class Grant implements AutoCloseable{final long sequence;final AbilityExecution execution;final Player target;
|
||||
final Consumer<Accepted> observation;final Consumer<Double> onEnd;double remaining;boolean closed;
|
||||
Grant(AbilityExecution e,Player t,double amount,Consumer<Accepted> observation,Consumer<Double> onEnd){
|
||||
sequence=++sequences;execution=e;target=t;remaining=amount;this.observation=observation;this.onEnd=onEnd;}
|
||||
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));if(onEnd!=null)try{onEnd.accept(Math.max(0,remaining));}catch(RuntimeException ignored){/* Cleanup is independent of recording. */}}
|
||||
}
|
||||
private final EquipmentSessions sessions;private final PartyRoster parties;private final Consumer<Runnable> nextTick;private final Consumer<Support> supports;
|
||||
private final Map<UUID,List<Grant>> grants=new HashMap<>();private long sequences;private boolean closed;
|
||||
private final Map<UUID,List<Grant>> grants=new HashMap<>();
|
||||
private final Set<EntityDamageEvent> pending=Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private long sequences;private boolean closed;
|
||||
public TemporaryHealth(EquipmentSessions sessions,PartyRoster parties,Consumer<Runnable> nextTick,Consumer<Support> supports){this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.nextTick=Objects.requireNonNull(nextTick);this.supports=Objects.requireNonNull(supports);}
|
||||
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){Objects.requireNonNull(execution);Objects.requireNonNull(target);if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);double after=owned(target.getUniqueId());addNative(target,Math.max(0,after-before));return grant;}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(closed||!(event.getEntity() instanceof Player target))return;var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());if(!Double.isFinite(before)||before<=0||owned<=0)return;Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;var aided=sessions.active(target.getUniqueId()).orElse(null);boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);UUID enemy=attacker==null?null:attacker.getUniqueId();
|
||||
try{nextTick.accept(()->consume(target,before,owned,owner,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
|
||||
private void consume(Player target,double before,double ownedBefore,Grant owner,EquipmentSessions.Active aided,UUID enemy){if(closed)return;double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));if(!Double.isFinite(consumed)||consumed<=0)return;var list=grants.get(target.getUniqueId());if(list==null)return;var amount=BigDecimal.valueOf(consumed).stripTrailingZeros();owner.execution.report(AbilityReport.Type.PREVENTION,target,amount);for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,owner.execution.input().session().character(),aided,amount));}
|
||||
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){
|
||||
return grantObserved(execution,target,amount,null);
|
||||
}
|
||||
public AutoCloseable grantObserved(AbilityExecution execution,Player target,double amount,Consumer<Accepted> observation){
|
||||
return grantObserved(execution,target,amount,observation,null);
|
||||
}
|
||||
public AutoCloseable grantObserved(AbilityExecution execution,Player target,double amount,
|
||||
Consumer<Accepted> observation,Consumer<Double> onEnd){
|
||||
Objects.requireNonNull(execution);Objects.requireNonNull(target);
|
||||
if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");
|
||||
double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount,observation,onEnd);
|
||||
addNative(target,Math.max(0,amount-before)); // Do not retain an owned grant if the native setter refuses it.
|
||||
grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);
|
||||
return grant;
|
||||
}
|
||||
public Optional<EquipmentSessions.Active> currentSession(Player target){
|
||||
return sessions.active(target.getUniqueId()).filter(sessions::current);
|
||||
}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){
|
||||
if(closed||pending.contains(event)||!(event.getEntity() instanceof Player target)
|
||||
||!Double.isFinite(event.getDamage())||event.getDamage()<=0)return;
|
||||
var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;
|
||||
double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());
|
||||
if(!Double.isFinite(before)||before<=0||owned<=0)return;
|
||||
Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;
|
||||
var targetAtHit=auditLocation(target);var attackerAtHit=auditLocation(attacker);
|
||||
var aided=sessions.active(target.getUniqueId()).orElse(null);
|
||||
boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);
|
||||
UUID enemy=attacker==null?null:attacker.getUniqueId();double incoming=event.getDamage();
|
||||
pending.add(event);
|
||||
try{nextTick.accept(()->{
|
||||
try{consume(event,target,before,owned,owner,qualifies?aided:null,enemy,attacker,incoming,targetAtHit,attackerAtHit);}
|
||||
finally{pending.remove(event);}
|
||||
});}
|
||||
catch(RuntimeException ignored){pending.remove(event);}
|
||||
}
|
||||
private void consume(EntityDamageEvent event,Player target,double before,double ownedBefore,Grant owner,
|
||||
EquipmentSessions.Active aided,UUID enemy,Entity attacker,double incoming,
|
||||
org.bukkit.Location targetAtHit,org.bukkit.Location attackerAtHit){
|
||||
if(closed||event.isCancelled()||!Double.isFinite(event.getDamage())||event.getDamage()<=0)return;
|
||||
double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));
|
||||
if(!Double.isFinite(consumed)||consumed<=0)return;
|
||||
var list=grants.get(target.getUniqueId());if(list==null||owner.closed||!list.contains(owner)||!owner.execution.stillCurrent())return;
|
||||
var amount=BigDecimal.valueOf(consumed).stripTrailingZeros();
|
||||
try{owner.execution.report(AbilityReport.Type.PREVENTION,target,amount);}
|
||||
catch(RuntimeException ignored){/* Feedback must not affect accepted absorption. */}
|
||||
for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);
|
||||
list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());
|
||||
if(owner.observation!=null)try{owner.observation.accept(new Accepted(target,attacker,incoming,before,after,
|
||||
consumed,Math.max(0,owner.remaining),targetAtHit,attackerAtHit));}
|
||||
catch(RuntimeException ignored){/* Audit must not affect native absorption or rewards. */}
|
||||
if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,owner.execution.input().session().character(),aided,amount));
|
||||
}
|
||||
private static org.bukkit.Location auditLocation(Entity entity){
|
||||
if(entity==null)return null;
|
||||
try{var location=entity.getLocation();return location==null?null:location.clone();}
|
||||
catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private boolean sameParty(Player source,Player target){var a=parties.partyOf(source.getUniqueId()).orElse(null);var b=parties.partyOf(target.getUniqueId()).orElse(null);return a!=null&&b!=null&&a.id().equals(b.id());}
|
||||
private Grant strongest(UUID target){Grant result=null;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>0&&grant.execution.stillCurrent()&&(result==null||grant.remaining>result.remaining||grant.remaining==result.remaining&&grant.sequence>result.sequence))result=grant;return result;}
|
||||
private double owned(UUID target){double result=0;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>result)result=grant.remaining;return result;}
|
||||
private static Entity attacker(EntityDamageByEntityEvent event){Entity causing=event.getDamageSource().getCausingEntity();if(causing!=null)return causing;if(event.getDamager() instanceof Projectile p){ProjectileSource shooter=p.getShooter();if(shooter instanceof Entity e)return e;}return event.getDamager();}
|
||||
private static void addNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount(),next=current+amount;if(!Double.isFinite(current)||current<0||!Double.isFinite(next))throw new IllegalStateException("Temporary health exceeds native range");target.setAbsorptionAmount(next);}
|
||||
private static void removeNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount();if(Double.isFinite(current)&¤t>=0)target.setAbsorptionAmount(Math.max(0,current-amount));}
|
||||
@Override public void close(){if(closed)return;for(var list:List.copyOf(grants.values()))for(var grant:List.copyOf(list))grant.close();closed=true;grants.clear();}
|
||||
@Override public void close(){if(closed)return;for(var list:List.copyOf(grants.values()))for(var grant:List.copyOf(list))grant.close();closed=true;grants.clear();pending.clear();}
|
||||
}
|
||||
@@ -55,13 +55,13 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
|
||||
private final class Area implements AutoCloseable {
|
||||
final long sequence = ++sequences; final AbilityExecution execution; final Player source;
|
||||
final Location origin; final Settings captured; final Map<UUID, Benefit> recipients = new LinkedHashMap<>();
|
||||
boolean closed;
|
||||
UUID application; String endReason = "interrupted"; boolean closed;
|
||||
Area(AbilityExecution execution, Location origin, Settings captured) {
|
||||
this.execution = execution; this.source = execution.input().player(); this.origin = origin; this.captured = captured;
|
||||
}
|
||||
void refresh() {
|
||||
if (closed) return;
|
||||
if (!sourceCurrent()) { execution.finish(); return; }
|
||||
if (!sourceCurrent()) { endReason = "eligibility_changed"; execution.resolve("interrupted"); execution.finish(); return; }
|
||||
var eligible = candidates();
|
||||
for (var entry : List.copyOf(recipients.entrySet())) if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
|
||||
for (var entry : eligible.entrySet()) recipients.computeIfAbsent(entry.getKey(), ignored -> new Benefit(this, entry.getValue()));
|
||||
@@ -104,20 +104,36 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; areas.remove(sequence, this);
|
||||
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
|
||||
if (application != null) execution.effectEnded(application, endReason);
|
||||
}
|
||||
}
|
||||
|
||||
private final class Benefit implements AutoCloseable {
|
||||
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance; boolean closed;
|
||||
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance;
|
||||
UUID application; boolean closed;
|
||||
Benefit(Area area, Player player) {
|
||||
this.area = area; this.player = player;
|
||||
BooleanSupplier current = () -> !closed && area.eligible(player);
|
||||
bonus = bonuses.grant(area.execution, player, area.captured.damageBonusPercent(), current);
|
||||
resistance = resistance(player, area.captured.knockbackResistancePercent(), current);
|
||||
try {
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("damage_bonus_percent", area.captured.damageBonusPercent().toPlainString());
|
||||
evidence.put("knockback_resistance_percent", area.captured.knockbackResistancePercent().toPlainString());
|
||||
evidence.put("area_location", locationData(area.origin));
|
||||
if (active != null) {
|
||||
evidence.put("recipient_character_id", active.character().toString());
|
||||
evidence.put("recipient_session_id", active.session().toString());
|
||||
}
|
||||
application = area.execution.effectApplied("war_banner_benefit", player, evidence);
|
||||
} catch (RuntimeException ignored) { /* Native grant remains authoritative despite audit failure. */ }
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
|
||||
closeResource(bonus); closeResource(resistance);
|
||||
if (application != null) area.execution.effectEnded(application,
|
||||
area.closed ? area.endReason : "recipient_eligibility_changed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +218,14 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return; var captured = from(execution.definition());
|
||||
var origin = placement(execution.input().player(), captured.placementRange());
|
||||
if (origin == null) { execution.finish(); return; }
|
||||
if (origin == null) { execution.resolve("miss", Map.of("reason", "placement_unavailable")); execution.finish(); return; }
|
||||
var area = new Area(execution, origin, captured); areas.put(area.sequence, area); execution.own(area);
|
||||
area.refresh(); execution.later(captured.duration(), execution::finish);
|
||||
area.application = execution.effectApplied("war_banner_area", null,
|
||||
Map.of("area_location", locationData(origin), "radius", captured.radius(),
|
||||
"vertical_range", captured.verticalRange(), "duration_seconds", seconds(captured.duration())));
|
||||
area.refresh(); execution.later(captured.duration(), () -> {
|
||||
area.endReason = "expired"; execution.resolve("expired"); execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
private AutoCloseable resistance(Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
@@ -231,6 +252,13 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
|
||||
return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;
|
||||
} catch(RuntimeException invalid){return false;}
|
||||
}
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
var world = location == null ? null : location.getWorld();
|
||||
if (world == null || world.getUID() == null || world.getName() == null)
|
||||
return Map.of("location_status", "unavailable");
|
||||
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
|
||||
"x", location.getX(), "y", location.getY(), "z", location.getZ());
|
||||
}
|
||||
private static double distance(Location origin, Player player) {
|
||||
try { var at=player.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;
|
||||
double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;
|
||||
|
||||
@@ -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);}
|
||||
|
||||
@@ -38,6 +38,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, games.dmg.fantasystick.events.BookEventBridge.Request> pendingBookEvents = new ConcurrentHashMap<>();
|
||||
private games.dmg.fantasystick.events.BookEventBridge bookEvents;
|
||||
private games.dmg.fantasystick.events.ProgressionEventBridge progressionEvents;
|
||||
private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents;
|
||||
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
|
||||
private final Map<UUID, Object> adminPending = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, AdminView> adminViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, SavedView> savedViews = new ConcurrentHashMap<>();
|
||||
@@ -58,14 +61,15 @@ 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) { }
|
||||
private record SavedView(String nonce, UUID administrator, UUID character, int previous, int next) { }
|
||||
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
|
||||
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
|
||||
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
|
||||
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision, CharacterRecord before) { }
|
||||
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids, CharacterRecord before) { }
|
||||
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids, CharacterRecord before) { }
|
||||
private record RareView(String nonce,ControlInput input,java.util.Map<String,java.math.BigInteger> quantities){}
|
||||
private record ReskillView(String nonce,ControlInput input,games.dmg.fantasystick.shop.RareReskillService.Preview preview){}
|
||||
private final Map<UUID, View> views = new ConcurrentHashMap<>();
|
||||
@@ -120,6 +124,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
public void attachRareInventory(games.dmg.fantasystick.shop.RareInventoryDirectory inventory,games.dmg.fantasystick.shop.RarePotionService potions,games.dmg.fantasystick.shop.RareReskillService reskills,games.dmg.fantasystick.shop.RareMagicCoreService magicCores){if(closed||rareInventory!=null)throw new IllegalStateException("Rare inventory already attached or books closed");rareInventory=Objects.requireNonNull(inventory);rarePotions=Objects.requireNonNull(potions);rareReskills=Objects.requireNonNull(reskills);rareMagicCores=Objects.requireNonNull(magicCores);}
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.BookEventBridge events){if(closed||bookEvents!=null)throw new IllegalStateException("Book events already attached or controller closed");bookEvents=Objects.requireNonNull(events);}
|
||||
public void attachProgressionEvents(games.dmg.fantasystick.events.ProgressionEventBridge events){if(closed||progressionEvents!=null)throw new IllegalStateException("Progression events already attached or controller closed");progressionEvents=Objects.requireNonNull(events);}
|
||||
public void attachAbilityEvents(games.dmg.fantasystick.events.AbilityEventBridge events){if(closed||abilityEvents!=null)throw new IllegalStateException("Ability events already attached or controller closed");abilityEvents=Objects.requireNonNull(events);}
|
||||
public void attachPassiveEvents(games.dmg.fantasystick.events.PassiveEventBridge events){if(closed||passiveEvents!=null)throw new IllegalStateException("Passive events already attached or controller closed");passiveEvents=Objects.requireNonNull(events);}
|
||||
|
||||
@Override public void accept(ControlInput input) { load(input, 0, true); }
|
||||
|
||||
@@ -238,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();
|
||||
@@ -249,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&¤t!=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&¤t!=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();
|
||||
@@ -312,7 +325,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
meta.setTitle("Character");
|
||||
meta.setAuthor("Fantasy Stick");
|
||||
|
||||
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision()) : null;
|
||||
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision(), record) : null;
|
||||
var allOverview = overviewPages(record, subject, statView, savedState);
|
||||
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
|
||||
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
|
||||
@@ -335,11 +348,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
if ((!definitions.isEmpty() || !passiveDefinitions.isEmpty()) && end == allOverview.size()) {
|
||||
var abilityView = interactive ? new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
|
||||
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet()), record) : null;
|
||||
if (interactive && !definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
|
||||
var passiveView = interactive ? new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
|
||||
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet()), record) : null;
|
||||
if (interactive && !passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
|
||||
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.passive.PassiveDefinition::id,
|
||||
@@ -392,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));
|
||||
@@ -769,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){
|
||||
@@ -789,49 +802,53 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
public void statAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
if (closed || !STATS.equals(event.getIdentifier())
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
|
||||
var player = connection.getPlayer();
|
||||
var player = connection.getPlayer();if(event.getTag()==null)return;String raw=event.getTag().string();if(raw.length()>80)return;var match=STAT_PAYLOAD.matcher(raw);if(!match.matches())return;var stat=Stat.valueOf(match.group(2));
|
||||
var view = statViews.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player || event.getTag() == null) return;
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 80) return;
|
||||
var match = STAT_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))
|
||||
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
var stat = Stat.valueOf(match.group(2));
|
||||
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1))
|
||||
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) { refuseStat(player,stat,"stale_forged_or_repeated_action"); return; }
|
||||
var currentHealth = java.math.BigDecimal.valueOf(player.getHealth());
|
||||
var nativeLimit = NativeHealthLimit.read();
|
||||
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
|
||||
.whenComplete((purchase, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> finishPurchase(view, stat, purchase, failure));
|
||||
} catch (RuntimeException rejected) { report(view.input(), rejected); }
|
||||
});
|
||||
var nativeMaximum=java.math.BigDecimal.valueOf(java.util.Objects.requireNonNull(player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue());
|
||||
var audit=progressionEvents==null?null:progressionEvents.begin(view.input(),view.before(),stat,currentHealth,nativeMaximum,nativeLimit);
|
||||
try {
|
||||
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
|
||||
.whenComplete((purchase, failure) -> {
|
||||
try { nextTick.accept(() -> finishPurchase(view, stat, audit, purchase, failure)); }
|
||||
catch (RuntimeException rejected) { if(audit!=null){if(purchase!=null&&purchase.status()==CharacterStorage.StatPurchaseStatus.APPLIED)progressionEvents.increased(audit,purchase.record().orElseThrow(),"scheduler_rejected_before_native_publication",purchase.record().orElseThrow().health());else progressionEvents.failed(audit,"scheduler_rejected");}report(view.input(), rejected); }
|
||||
});
|
||||
} catch (RuntimeException failure) { if(audit!=null)progressionEvents.failed(audit,"purchase_rejected");report(view.input(),failure); }
|
||||
}
|
||||
|
||||
private void finishPurchase(StatView view, Stat stat, CharacterStorage.StatPurchase purchase, Throwable failure) {
|
||||
private void refuseStat(org.bukkit.entity.Player player,Stat stat,String reason){if(progressionEvents==null)return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)progressionEvents.refused(player,active,current,stat,NativeHealthLimit.read(),reason);}
|
||||
|
||||
private void finishPurchase(StatView view, Stat stat, games.dmg.fantasystick.events.ProgressionEventBridge.Action audit, CharacterStorage.StatPurchase purchase, Throwable failure) {
|
||||
var input = view.input();
|
||||
if (closed || !input.stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
report(input, failure);
|
||||
input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
|
||||
if(audit!=null)progressionEvents.failed(audit,"save_failed");report(input, failure);
|
||||
if(!closed&&input.player().isOnline())input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
|
||||
return;
|
||||
}
|
||||
if (purchase.status() == CharacterStorage.StatPurchaseStatus.APPLIED) {
|
||||
var saved = purchase.record().orElseThrow();
|
||||
if(closed||!input.stillCurrent()){if(audit!=null)progressionEvents.increased(audit,saved,closed?"plugin_shutdown_before_native_publication":"session_changed_before_native_publication",saved.health());return;}
|
||||
try {
|
||||
if (!sessions.acknowledgeProgress(input.session(), view.revision(), saved)) {
|
||||
if(audit!=null)progressionEvents.increased(audit,saved,"session_changed_before_native_publication",saved.health());
|
||||
input.player().sendMessage("Stat purchase was saved; reopen the character after the session transition.");
|
||||
return;
|
||||
}
|
||||
} catch (RuntimeException nativeFailure) {
|
||||
report(input, nativeFailure);
|
||||
if(audit!=null)progressionEvents.increased(audit,saved,"native_publication_failed",saved.health());report(input, nativeFailure);
|
||||
input.player().sendMessage("Stat purchase was saved, but native health refresh failed; exit for recovery.");
|
||||
return;
|
||||
}
|
||||
if(audit!=null)progressionEvents.increased(audit,saved,"acknowledged",java.math.BigDecimal.valueOf(input.player().getHealth()));
|
||||
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
|
||||
load(input, 0, false);
|
||||
return;
|
||||
}
|
||||
if(audit!=null)progressionEvents.refused(audit,purchase.status().name().toLowerCase(java.util.Locale.ROOT),purchase.record().orElse(null));
|
||||
if(closed||!input.stillCurrent())return;
|
||||
String message = switch (purchase.status()) {
|
||||
case STALE -> "That character page is stale; reopen it before spending points.";
|
||||
case NO_POINTS -> "This character has no unspent points.";
|
||||
@@ -849,18 +866,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
if (closed || abilityEngine == null || !unlock && !unlearn
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|
||||
|| event.getTag() == null) return;
|
||||
var player = connection.getPlayer();
|
||||
var player = connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String ability=match.group(2);
|
||||
var view = abilityViews.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player) return;
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 120) return;
|
||||
var match = ABILITY_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|
||||
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
String ability = match.group(2);
|
||||
boolean audited=unlearn||unlock&&("revive".equals(ability)||abilityEngine.eventsEnabledFor(ability));
|
||||
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(ability)
|
||||
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(audited)refuseAbilityMutation(player,ability,unlock,"stale_forged_or_repeated_action");return;}
|
||||
var audit=audited&&abilityEvents!=null?abilityEvents.begin(view.input(),view.before(),ability,unlock):null;
|
||||
var operation = unlock ? abilityEngine.purchase(view.input(), ability, view.revision())
|
||||
: abilityEngine.unlearn(view.input(), ability, view.revision());
|
||||
operation.whenComplete((mutation, failure) -> {
|
||||
if(audit!=null){if(failure!=null)abilityEvents.failed(audit,"save_or_native_cleanup_uncertain");else abilityEvents.resolved(audit,mutation);}
|
||||
if (closed || !view.input().stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
report(view.input(), failure);
|
||||
@@ -872,6 +887,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
});
|
||||
}
|
||||
|
||||
private void refuseAbilityMutation(org.bukkit.entity.Player player,String ability,boolean learn,String reason){if(abilityEvents==null||abilities.find(ability).isEmpty())return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)abilityEvents.refused(player,active,current,ability,learn,reason);}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void passiveAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
boolean unlock = PASSIVE_UNLOCK.equals(event.getIdentifier());
|
||||
@@ -879,16 +896,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
if (closed || passiveEngine == null || !unlock && !unlearn
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|
||||
|| event.getTag() == null) return;
|
||||
var player = connection.getPlayer(); var view = passiveViews.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player) return;
|
||||
String raw = event.getTag().string(); if (raw.length() > 120) return;
|
||||
var match = ABILITY_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|
||||
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
String passive = match.group(2);
|
||||
var player=connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String passive=match.group(2);var definition=passives.find(passive).orElse(null);if(definition==null)return;var view=passiveViews.get(player.getUniqueId());boolean owned=games.dmg.fantasystick.passive.QuickerRegenerationI.ID.equals(passive);
|
||||
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(passive)
|
||||
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(owned)refusePassive(player,definition,unlock,"stale_forged_or_repeated_action");return;}
|
||||
var audit=owned&&passiveEvents!=null?passiveEvents.begin(view.input(),view.before(),definition,unlock):null;
|
||||
var operation = unlock ? passiveEngine.purchase(view.input(), passive, view.revision())
|
||||
: passiveEngine.unlearn(view.input(), passive, view.revision());
|
||||
operation.whenComplete((mutation, failure) -> {
|
||||
if(audit!=null){if(failure!=null)passiveEvents.failed(audit,"save_or_native_publication_uncertain");else passiveEvents.resolved(audit,mutation);}
|
||||
if (closed || !view.input().stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
report(view.input(), failure);
|
||||
@@ -900,6 +915,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
});
|
||||
}
|
||||
|
||||
private void refusePassive(org.bukkit.entity.Player player,games.dmg.fantasystick.passive.PassiveDefinition definition,boolean learn,String reason){if(passiveEvents==null)return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)passiveEvents.refused(player,active,current,definition,learn,reason);}
|
||||
|
||||
private static String passiveMessage(boolean unlock, String passive,
|
||||
CharacterStorage.PassiveMutationStatus status) {
|
||||
return switch (status) {
|
||||
|
||||
@@ -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"; }
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.ability.AbilityDefinition;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
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;
|
||||
|
||||
/** 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,
|
||||
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;
|
||||
|
||||
public AbilityCastEventBridge(Consumer<EventInput> sink, Clock clock) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
public Cast begin(ControlInput input, CharacterRecord before, AbilityDefinition definition) {
|
||||
try {
|
||||
var player = input.player();
|
||||
var target = input.target();
|
||||
var targetAtRequest = target == null ? null : target.getLocation();
|
||||
var cast = new Cast(UUID.randomUUID(),
|
||||
new EventInput.Context(player.getUniqueId(), player.getName(), input.session().character(),
|
||||
before.name(), input.session().session(), location(player.getLocation())),
|
||||
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(), input.attackStrength(), input.heldTicks(), input.slot());
|
||||
emit(cast, "action.observed", "observed", Map.of("action_kind", "ability_cast"));
|
||||
return cast;
|
||||
} catch (RuntimeException ignored) {
|
||||
return null; // Auditing may never refuse a cast or change its authority.
|
||||
}
|
||||
}
|
||||
|
||||
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>();
|
||||
definition.parameters().forEach((key, value) -> settings.put(key, value.toPlainString()));
|
||||
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);
|
||||
} catch (RuntimeException ignored) { /* Capturing metadata cannot refuse a saved cast. */ }
|
||||
}
|
||||
|
||||
public void resolved(Cast cast, String outcome, String reason) {
|
||||
resolved(cast, outcome, reason, Map.of());
|
||||
}
|
||||
|
||||
public void resolved(Cast cast, String outcome, String reason, Map<String, Object> evidence) {
|
||||
if (cast == null) return;
|
||||
var data = new LinkedHashMap<String, Object>(evidence);
|
||||
data.put("reason", reason);
|
||||
emit(cast, "ability.resolved", outcome, data);
|
||||
}
|
||||
|
||||
public UUID effectApplied(Cast cast, String effectId, Entity target, Map<String, Object> evidence) {
|
||||
Objects.requireNonNull(effectId);
|
||||
Objects.requireNonNull(evidence);
|
||||
UUID application = UUID.randomUUID();
|
||||
if (cast != null) {
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>(evidence);
|
||||
data.put("effect_id", effectId);
|
||||
data.put("application_id", application.toString());
|
||||
if (target != null) {
|
||||
data.put("target_entity_id", target.getUniqueId().toString());
|
||||
data.put("target_type", entityType(target));
|
||||
var place = target.getLocation();
|
||||
if (place != null) data.put("target_location", locationData(place));
|
||||
}
|
||||
emit(cast, "effect.applied", "applied", data, application);
|
||||
} catch (RuntimeException ignored) { /* An observation failure cannot interrupt an applied effect. */ }
|
||||
}
|
||||
return application;
|
||||
}
|
||||
|
||||
public void acceptedDamage(Cast cast, Entity target, double requested, double actual,
|
||||
Entity direct, Map<String, Object> evidence) {
|
||||
if (cast == null || !Double.isFinite(requested) || !Double.isFinite(actual) || actual <= 0) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>(evidence);
|
||||
var application = UUID.randomUUID();
|
||||
data.put("application_id", application.toString());
|
||||
data.put("causation_id", cast.actionId().toString());
|
||||
data.put("requested_amount", requested);
|
||||
data.put("actual_amount", actual);
|
||||
data.put("target_entity_id", target.getUniqueId().toString());
|
||||
data.put("target_type", entityType(target));
|
||||
var targetAtApplication = target.getLocation();
|
||||
if (targetAtApplication != null) data.put("target_location", locationData(targetAtApplication));
|
||||
if (target instanceof org.bukkit.entity.Player player) data.put("target_player_id", player.getUniqueId().toString());
|
||||
data.put("direct_entity_id", direct.getUniqueId().toString());
|
||||
data.put("direct_entity_type", entityType(direct));
|
||||
emit(cast, "combat.damage", "accepted", data, application);
|
||||
} catch (RuntimeException ignored) { /* Native damage remains authoritative even when auditing fails. */ }
|
||||
}
|
||||
|
||||
public void healed(Cast cast, org.bukkit.entity.Player target, UUID aidedCharacter, UUID aidedSession,
|
||||
CharacterRecord saved, double requested, double actual, double before, double after,
|
||||
double maximum) {
|
||||
if (cast == null || !Double.isFinite(actual) || actual <= 0) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
var application = UUID.randomUUID();
|
||||
data.put("application_id", application.toString());
|
||||
data.put("causation_id", cast.actionId().toString());
|
||||
data.put("target_player_id", target.getUniqueId().toString());
|
||||
data.put("target_character_id", aidedCharacter.toString());
|
||||
data.put("target_session_id", aidedSession.toString());
|
||||
data.put("target_location", locationData(target.getLocation()));
|
||||
data.put("after_revision", saved.revision().toString());
|
||||
data.put("requested_amount", decimal(requested));
|
||||
data.put("actual_amount", decimal(actual));
|
||||
data.put("health_before", decimal(before));
|
||||
data.put("health_after", decimal(after));
|
||||
data.put("maximum_health", decimal(maximum));
|
||||
emit(cast, "combat.healed", "acknowledged", data, application);
|
||||
} catch (RuntimeException ignored) { /* Healing remains authoritative despite logging failure. */ }
|
||||
}
|
||||
|
||||
private static String decimal(double value) {
|
||||
if (!Double.isFinite(value)) throw new IllegalArgumentException("Non-finite health observation");
|
||||
return java.math.BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
|
||||
}
|
||||
|
||||
public void cleansed(Cast cast, org.bukkit.entity.Player target, UUID aidedCharacter,
|
||||
UUID aidedSession, java.util.List<String> removed) {
|
||||
if (cast == null || removed == null || removed.isEmpty()) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
var application = UUID.randomUUID();
|
||||
data.put("application_id", application.toString());
|
||||
data.put("causation_id", cast.actionId().toString());
|
||||
data.put("target_player_id", target.getUniqueId().toString());
|
||||
data.put("target_character_id", aidedCharacter.toString());
|
||||
data.put("target_session_id", aidedSession.toString());
|
||||
data.put("target_location", locationData(target.getLocation()));
|
||||
data.put("self_target", aidedCharacter.equals(cast.context().characterId()));
|
||||
data.put("removed_effects", java.util.List.copyOf(removed));
|
||||
data.put("actual_count", Integer.toString(removed.size()));
|
||||
emit(cast, "combat.cleansed", "accepted", data, application);
|
||||
} catch (RuntimeException ignored) { /* Native cleanse remains authoritative. */ }
|
||||
}
|
||||
|
||||
public void prevented(Cast cast, org.bukkit.entity.Player ally, UUID aidedCharacter,
|
||||
UUID aidedSession, Entity attacker, double incoming, double actual,
|
||||
double remaining, UUID bondApplication) {
|
||||
if (cast == null || !Double.isFinite(actual) || actual <= 0) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
var application = UUID.randomUUID();
|
||||
data.put("application_id", application.toString());
|
||||
data.put("causation_id", cast.actionId().toString());
|
||||
if (bondApplication != null) data.put("bond_application_id", bondApplication.toString());
|
||||
data.put("target_player_id", ally.getUniqueId().toString());
|
||||
data.put("target_character_id", aidedCharacter.toString());
|
||||
data.put("target_session_id", aidedSession.toString());
|
||||
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));
|
||||
emit(cast, "combat.prevented", "accepted", data, application);
|
||||
} 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) {
|
||||
if (cast == null || !Double.isFinite(finalDamage) || finalDamage <= 0
|
||||
|| !Double.isFinite(bonus) || bonus <= 0) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
var application = UUID.randomUUID();
|
||||
data.put("application_id", application.toString());
|
||||
data.put("causation_id", cast.actionId().toString());
|
||||
data.put("attacker_player_id", attacker.getUniqueId().toString());
|
||||
if (attacking != null) {
|
||||
data.put("attacker_character_id", attacking.character().toString());
|
||||
data.put("attacker_session_id", attacking.session().toString());
|
||||
}
|
||||
data.put("target_entity_id", target.getUniqueId().toString());
|
||||
data.put("target_type", entityType(target));
|
||||
var location = target.getLocation();
|
||||
if (location != null && location.getWorld() != null) data.put("target_location", locationData(location));
|
||||
data.put("base_amount", decimal(base));
|
||||
data.put("actual_amount", decimal(bonus));
|
||||
data.put("final_amount", decimal(finalDamage));
|
||||
data.put("winning_bonus_percent", percent.stripTrailingZeros().toPlainString());
|
||||
data.put("selection", "strongest_applicable_only");
|
||||
emit(cast, "combat.boosted", "accepted", data, application);
|
||||
} catch (RuntimeException ignored) { /* Native combat remains authoritative. */ }
|
||||
}
|
||||
|
||||
private static String entityType(Entity entity) {
|
||||
if (entity instanceof org.bukkit.entity.Player) return "PLAYER";
|
||||
var type = entity.getType();
|
||||
return type == null ? "unavailable" : type.name();
|
||||
}
|
||||
|
||||
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;
|
||||
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) {
|
||||
emit(cast, type, outcome, extra, null);
|
||||
}
|
||||
|
||||
private void emit(Cast cast, String type, String outcome, Map<String, Object> extra, UUID application) {
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>(extra);
|
||||
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());
|
||||
if (!cast.targetLocation().isEmpty()) data.put("target_location", cast.targetLocation());
|
||||
}
|
||||
var identity = application == null ? cast.actionId() : application;
|
||||
sink.accept(new EventInput(stable(identity, type), cast.actionId(),
|
||||
type.equals("action.observed") ? cast.requestedAt() : clock.instant(), type, cast.context(), data));
|
||||
} catch (RuntimeException ignored) { /* A writer failure cannot authorize or undo gameplay. */ }
|
||||
}
|
||||
|
||||
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 Map<String, Object> locationData(Location value) {
|
||||
return Map.of("world_id", value.getWorld().getUID().toString(), "world_name", value.getWorld().getName(),
|
||||
"x", value.getX(), "y", value.getY(), "z", value.getZ(), "captured_at", clock.instant().toString());
|
||||
}
|
||||
|
||||
private static UUID stable(UUID identity, String type) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:ability-cast:" + identity + ":" + type)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.command.BlockCommandSender;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Authorized ability-setting reads and acknowledged updates; no write authority. */
|
||||
public final class AbilityConfigEventBridge {
|
||||
public record Actor(String kind, UUID playerId, String playerName) { }
|
||||
|
||||
private final Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
|
||||
public AbilityConfigEventBridge(Consumer<EventInput> sink, Clock clock) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
/** Capture account identity on the command thread, never on the file worker. */
|
||||
public Actor capture(CommandSender sender) {
|
||||
try {
|
||||
if (sender instanceof Player player) return new Actor("player", player.getUniqueId(), player.getName());
|
||||
return new Actor(sender instanceof BlockCommandSender ? "command_block" : "console", null, null);
|
||||
} catch (RuntimeException ignored) {
|
||||
return null; // Omit uncertain identity, never refuse the authorized action.
|
||||
}
|
||||
}
|
||||
|
||||
public void read(Actor actor, String abilityId, String key, String value) {
|
||||
emit("config.read", actor, abilityId, key, null, value);
|
||||
}
|
||||
|
||||
public void changed(Actor actor, String abilityId, String key, String before, String after) {
|
||||
emit("config.changed", actor, abilityId, key, before, after);
|
||||
}
|
||||
|
||||
private void emit(String type, Actor actor, String abilityId, String key, String before, String after) {
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", actor.kind());
|
||||
data.put("ability_id", abilityId);
|
||||
data.put("key", key);
|
||||
if (before != null) data.put("before", before);
|
||||
data.put("after", after);
|
||||
data.put("application", "future_casts_only");
|
||||
data.put("outcome", "acknowledged");
|
||||
var action = UUID.randomUUID();
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:ability-config:" + action + ":" + type)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
var context = actor.playerId() == null ? null
|
||||
: new EventInput.Context(actor.playerId(), actor.playerName(), null, null, null, null);
|
||||
sink.accept(new EventInput(id, action, clock.instant(), type, context, data));
|
||||
} catch (RuntimeException ignored) { /* File logging cannot change an authorized configuration action. */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.ability.AbilityEngine;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
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;
|
||||
|
||||
/** Feature-owned exact ability-refund outcomes; never an authorization or refund authority. */
|
||||
public final class AbilityEventBridge {
|
||||
public record Action(UUID actionId,EventInput.Context context,String ability,boolean learn,CharacterRecord before){
|
||||
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(ability);Objects.requireNonNull(before);}
|
||||
}
|
||||
private final Consumer<EventInput> sink;private final Clock clock;
|
||||
public AbilityEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
|
||||
|
||||
public Action begin(ControlInput input,CharacterRecord before,String ability){return begin(input,before,ability,false);}
|
||||
public Action begin(ControlInput input,CharacterRecord before,String ability,boolean learn){
|
||||
var action=new Action(UUID.randomUUID(),context(input.player(),input.session(),before.name()),ability,learn,before);
|
||||
emit("action.observed",action,"observed",null,null,null);return action;
|
||||
}
|
||||
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,String ability,String reason){refused(player,active,before,ability,false,reason);}
|
||||
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,String ability,boolean learn,String reason){
|
||||
var action=new Action(UUID.randomUUID(),context(player,active,before.name()),ability,learn,before);emit("action.observed",action,"observed",null,null,null);emit("action.resolved",action,"refused",reason,null,null);
|
||||
}
|
||||
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null);}
|
||||
public void resolved(Action action,AbilityEngine.Mutation mutation){
|
||||
if(mutation!=null&&mutation.status()==games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutationStatus.APPLIED&&mutation.record()!=null){
|
||||
emit("action.resolved",action,"accepted","acknowledged",mutation.record(),"acknowledged");
|
||||
if(action.learn()){emit("ability.learned",action,"accepted","acknowledged",mutation.record(),"acknowledged");emit("ability.assigned",action,"accepted","acknowledged",mutation.record(),"acknowledged");}
|
||||
else emit("ability.unlearned",action,"accepted","acknowledged",mutation.record(),"acknowledged");
|
||||
}else emit("action.resolved",action,"refused",mutation==null?"unavailable":mutation.status().name().toLowerCase(java.util.Locale.ROOT),mutation==null?null:mutation.record(),null);
|
||||
}
|
||||
|
||||
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after,String nativePublication){
|
||||
var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"ability_learn":"ability_unlearn");data.put("ability_id",action.ability());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("outcome",outcome);
|
||||
var beforeCooldown=before.cooldowns().get(action.ability());if(beforeCooldown!=null)data.put("cooldown_before",beforeCooldown.toString());
|
||||
if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());var afterCooldown=after.cooldowns().get(action.ability());if(afterCooldown!=null)data.put("cooldown_after",afterCooldown.toString());if(action.learn()){data.put("ability_slot",after.loadout().indexOf(action.ability()));data.put("slot_index_base",0);}else{var cleared=new ArrayList<Integer>();for(int slot=0;slot<before.loadout().size();slot++)if(action.ability().equals(before.loadout().get(slot))&&(slot>=after.loadout().size()||!action.ability().equals(after.loadout().get(slot))))cleared.add(slot);data.put("cleared_slot_indexes",cleared);data.put("slot_index_base",0);}}
|
||||
if(nativePublication!=null){data.put("native_publication",nativePublication);if(!action.learn())data.put("owned_cleanup","invalidation_acknowledged_close_attempted");}
|
||||
if(reason!=null)data.put(type.startsWith("ability.")?"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(org.bukkit.entity.Player player,EquipmentSessions.Active active,String characterName){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),characterName,active.session(),location(player.getLocation()));}
|
||||
private EventInput.Location location(Location location){return new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact");}
|
||||
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:ability-mutation:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.AdminReviveSettings;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.BlockCommandSender;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Real administrator Tail restoration and acknowledged selection-timeout configuration. */
|
||||
public final class AdminRevivalEventBridge implements AutoCloseable {
|
||||
public record Actor(String kind, EventInput.Context context, UUID id) { }
|
||||
public record Selection(Actor actor, UUID targetId, String targetName,
|
||||
EventInput.Location targetLocation,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active targetSession) { }
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock;
|
||||
private final games.dmg.fantasystick.session.EquipmentSessions sessions;
|
||||
|
||||
public AdminRevivalEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, games.dmg.fantasystick.session.EquipmentSessions sessions,
|
||||
Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
outbox = new FeatureEventOutbox(data, "admin-revival-event-intents",
|
||||
java.util.Set.of("character.revived", "config.changed"), sink, failures);
|
||||
}
|
||||
|
||||
public Actor actor(CommandSender sender) {
|
||||
try {
|
||||
var player = sender instanceof Player value ? value : null;
|
||||
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
|
||||
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
|
||||
player.getName(), record == null ? null : record.id(),
|
||||
record == null ? null : record.name(), record == null ? null : active.session(), safeLocation(player)),
|
||||
player == null ? null : player.getUniqueId());
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public Selection selection(Player admin, Player target) {
|
||||
try { return new Selection(actor(admin), target.getUniqueId(), target.getName(),
|
||||
safeLocation(target), sessions.active(target.getUniqueId()).orElse(null)); }
|
||||
catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public void revived(Selection selection, RevivalStore.Reservation reservation,
|
||||
CharacterRecord dead, CharacterRecord restored, Player target) {
|
||||
if (selection == null || selection.actor() == null) return;
|
||||
try {
|
||||
var data = revivalData(reservation, restored, target);
|
||||
data.put("revision_before", dead.revision().toString());
|
||||
data.put("death_time", dead.diedAt().toString());
|
||||
data.put("death_age_seconds", Long.toString(Math.max(0, Duration.between(dead.diedAt(), clock.instant()).getSeconds())));
|
||||
data.put("recovery_reconciliation", false);
|
||||
location(data, "target_location", selection.targetLocation());
|
||||
targetSession(data, selection.targetSession());
|
||||
var actor = selection.actor();
|
||||
data.put("admin_player_id", selection.actor().id().toString());
|
||||
data.put("admin_player_name", actor.context().playerName());
|
||||
if (actor.context().location() == null) data.put("actor_location_status", "unavailable");
|
||||
durable("character.revived", reservation.id(), actor.context(), data);
|
||||
} catch (RuntimeException ignored) { /* Audit failure cannot consume or restore a Tail. */ }
|
||||
}
|
||||
|
||||
/** Reconciliation has the reservation's exact admin UUID, but never invents its old location or name. */
|
||||
public void recovered(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) {
|
||||
if (!reservation.administrative()) return;
|
||||
try {
|
||||
var data = revivalData(reservation, restored, target);
|
||||
data.put("actor_kind", "system");
|
||||
data.put("admin_player_id", reservation.casterCharacter().toString());
|
||||
data.put("admin_name_status", "unavailable");
|
||||
data.put("actor_location_status", "unavailable");
|
||||
data.put("revision_before", restored.revision().subtract(BigInteger.ONE).toString());
|
||||
data.put("death_time", restored.diedAt().toString());
|
||||
data.put("death_age_seconds", Long.toString(Math.max(0,
|
||||
Duration.between(restored.diedAt(), reservation.createdAt()).getSeconds())));
|
||||
data.put("recovery_reconciliation", true);
|
||||
location(data, "target_location", safeLocation(target));
|
||||
targetSession(data, sessions.active(target.getUniqueId()).orElse(null));
|
||||
durable("character.revived", reservation.id(), null, data);
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private Map<String, Object> revivalData(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("revival_kind", "admin_tail");
|
||||
data.put("revival_operation_id", reservation.id().toString());
|
||||
data.put("target_player_id", reservation.targetPlayer().toString());
|
||||
data.put("target_player_name", target.getName());
|
||||
data.put("character_id", restored.id().toString());
|
||||
data.put("character_name", restored.name());
|
||||
data.put("death_id", reservation.death().toString());
|
||||
data.put("revision_after", restored.revision().toString());
|
||||
data.put("tail_slot", reservation.sourceSlot());
|
||||
data.put("tail_custody_id", reservation.death().toString());
|
||||
data.put("sheet_slot", reservation.sourceAmount() == 1 ? reservation.sourceSlot() : reservation.destinationSlot());
|
||||
data.put("source_quantity_before", Integer.toString(reservation.sourceAmount()));
|
||||
data.put("source_quantity_after", Integer.toString(reservation.sourceAmount() - 1));
|
||||
data.put("restored_health", restored.health().toPlainString());
|
||||
data.put("maximum_health", restored.maximumHealth().toPlainString());
|
||||
data.put("cooldowns_retained", true);
|
||||
data.put("native_delivery", "acknowledged");
|
||||
data.put("outcome", "acknowledged");
|
||||
return data;
|
||||
}
|
||||
|
||||
public void read(Actor actor, String effective) {
|
||||
if (actor == null) return;
|
||||
var data = config(actor);
|
||||
data.put("effective", effective);
|
||||
data.put("outcome", "acknowledged_read");
|
||||
emit(UUID.randomUUID(), "config.read", actor.context(), data);
|
||||
}
|
||||
|
||||
public void changed(Actor actor, String before, AdminReviveSettings.Update saved) {
|
||||
if (actor == null) return;
|
||||
var data = config(actor);
|
||||
data.put("before", before);
|
||||
data.put("after", saved.value());
|
||||
data.put("effective", saved.value());
|
||||
data.put("outcome", "acknowledged");
|
||||
durable("config.changed", UUID.randomUUID(), actor.context(), data);
|
||||
}
|
||||
|
||||
private static Map<String, Object> config(Actor actor) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", actor.kind());
|
||||
data.put("key", AdminReviveSettings.KEY);
|
||||
data.put("applies_to", "later_selections");
|
||||
if (actor.context() == null) data.put("location_status", "not_applicable");
|
||||
else if (actor.context().location() == null) data.put("location_status", "unavailable");
|
||||
return data;
|
||||
}
|
||||
|
||||
private static void targetSession(Map<String, Object> data,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active) {
|
||||
if (active == null) { data.put("target_session_status", "no_active_session"); return; }
|
||||
data.put("target_session_id", active.session().toString());
|
||||
data.put("target_active_character_id", active.character().toString());
|
||||
data.put("target_session_status", "captured");
|
||||
}
|
||||
|
||||
private void location(Map<String, Object> data, String key, EventInput.Location value) {
|
||||
if (value == null) { data.put(key + "_status", "unavailable"); return; }
|
||||
data.put(key, Map.of("world_id", value.worldId().toString(), "world_name", value.worldName(),
|
||||
"x", value.x(), "y", value.y(), "z", value.z(),
|
||||
"captured_at", value.capturedAt().toString(), "quality", value.quality()));
|
||||
}
|
||||
|
||||
private EventInput.Location safeLocation(Player player) {
|
||||
try { return location(player.getLocation()); }
|
||||
catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
try {
|
||||
if (value == null || value.getWorld() == null) return null;
|
||||
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(),
|
||||
value.getX(), value.getY(), value.getZ(), clock.instant(), "exact");
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private void durable(String type, UUID action, EventInput.Context context, Map<String, Object> data) {
|
||||
try { outbox.accepted(new EventInput(id(action, type), action, clock.instant(), type, context, data)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
private void emit(UUID action, String type, EventInput.Context context, Map<String, Object> data) {
|
||||
try { sink.apply(new EventInput(id(action, type), action, clock.instant(), type, context, data)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
private static UUID id(UUID action, String kind) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:admin-revival:" + action + ":" + kind)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
|
||||
/** Immutable server-thread snapshots for the owned bound-stick input and its native hit. */
|
||||
public final class BasicAttackEventBridge {
|
||||
public record Action(UUID id, Instant observedAt, EventInput.Context actor, Map<String, Object> data) { }
|
||||
private final Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
|
||||
public BasicAttackEventBridge(Consumer<EventInput> sink, Clock clock) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
public Action observed(ControlInput input, CharacterRecord record, Entity target, Instant at,
|
||||
Instant ready, Double requested) {
|
||||
try {
|
||||
var player = input.player();
|
||||
var context = new EventInput.Context(player.getUniqueId(), player.getName(),
|
||||
record == null ? null : record.id(), record == null ? null : record.name(),
|
||||
record == null ? null : input.session().session(), location(player.getLocation()));
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("action_kind", "basic_attack");
|
||||
data.put("entry_point", "bound_slot_2_attack");
|
||||
data.put("slot_index", input.slot());
|
||||
data.put("control_kind", input.kind().name().toLowerCase(java.util.Locale.ROOT));
|
||||
data.put("source_player_id", player.getUniqueId().toString());
|
||||
data.put("source_character_id", input.session().character().toString());
|
||||
data.put("source_session_id", input.session().session().toString());
|
||||
if (record != null) data.put("strength", record.stats().get(games.dmg.fantasystick.progression.Stat.STR).toString());
|
||||
else data.put("strength_status", "unavailable");
|
||||
data.put("ready_deadline", (ready == null ? at : ready).toString());
|
||||
if (ready == null) data.put("ready_deadline_status", "ready_at_observation_no_prior_hit");
|
||||
if (requested != null) data.put("requested_amount", Double.toString(requested));
|
||||
else data.put("requested_amount_status", "not_computed");
|
||||
data.put("prior_cancelled", false);
|
||||
if (target != null && target.getUniqueId() != null) data.put("target_entity_id", target.getUniqueId().toString());
|
||||
if (target instanceof org.bukkit.entity.Player) data.put("target_entity_type", "player");
|
||||
else if (target != null && target.getType() != null)
|
||||
data.put("target_entity_type", target.getType().name().toLowerCase(java.util.Locale.ROOT));
|
||||
var sourceLocation = location(player.getLocation());
|
||||
var targetLocation = target == null ? null : location(target.getLocation());
|
||||
if (sourceLocation != null) data.put("source_location", coordinates(sourceLocation));
|
||||
else data.put("source_location_status", "unavailable");
|
||||
if (targetLocation != null) data.put("target_location", coordinates(targetLocation));
|
||||
else data.put("target_location_status", "unavailable");
|
||||
var action = new Action(UUID.randomUUID(), at, context, Map.copyOf(data));
|
||||
var values = new LinkedHashMap<String, Object>(data);
|
||||
values.put("outcome", "observed");
|
||||
emit(action, "action.observed", "observed", at, values);
|
||||
return action;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void resolved(Action action, String outcome, String reason, boolean nativePriorCancelled,
|
||||
boolean finalCancelled, Double finalAmount, Instant nextReady) {
|
||||
if (action == null) return;
|
||||
var values = new LinkedHashMap<String, Object>(action.data());
|
||||
values.put("outcome", outcome);
|
||||
values.put("reason", reason);
|
||||
values.put("native_prior_cancelled", nativePriorCancelled);
|
||||
values.put("final_cancelled", finalCancelled);
|
||||
if (finalAmount != null) values.put("final_amount", Double.toString(finalAmount));
|
||||
else values.put("final_amount_status", "unavailable");
|
||||
if (nextReady != null) values.put("next_ready_deadline", nextReady.toString());
|
||||
emit(action, "action.resolved", "resolved", clock.instant(), values);
|
||||
}
|
||||
|
||||
public void acceptedDamage(Action action, EntityDamageByEntityEvent event, Instant nextReady) {
|
||||
if (action == null) return;
|
||||
try {
|
||||
var values = new LinkedHashMap<String, Object>(action.data());
|
||||
values.put("outcome", "accepted");
|
||||
values.put("reason", "native_positive_hit");
|
||||
values.put("final_cancelled", false);
|
||||
values.put("final_amount", Double.toString(event.getFinalDamage()));
|
||||
values.put("next_ready_deadline", nextReady.toString());
|
||||
var target = event.getEntity();
|
||||
if (target instanceof org.bukkit.entity.Player player) values.put("target_player_id", player.getUniqueId().toString());
|
||||
var captured = location(target.getLocation());
|
||||
if (captured != null) values.put("target_location", coordinates(captured));
|
||||
else values.put("target_location_status", "unavailable");
|
||||
emit(action, "combat.damage", "damage", clock.instant(), values);
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location nativeLocation) {
|
||||
return nativeLocation == null || nativeLocation.getWorld() == null ? null
|
||||
: new EventInput.Location(nativeLocation.getWorld().getUID(), nativeLocation.getWorld().getName(),
|
||||
nativeLocation.getX(), nativeLocation.getY(), nativeLocation.getZ(), clock.instant(), "exact");
|
||||
}
|
||||
|
||||
private Map<String, Object> coordinates(EventInput.Location value) {
|
||||
return Map.of("world_id", value.worldId().toString(), "world_name", value.worldName(), "x", value.x(),
|
||||
"y", value.y(), "z", value.z(), "captured_at", value.capturedAt().toString());
|
||||
}
|
||||
|
||||
private void emit(Action action, String type, String stage, Instant time, Map<String, Object> values) {
|
||||
try {
|
||||
sink.accept(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:basic-attack:" + action.id() + ':'
|
||||
+ stage).getBytes(StandardCharsets.UTF_8)), action.id(), time, type, action.actor(), values));
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
}
|
||||
@@ -14,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,26 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.progression.CoinRules;
|
||||
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;
|
||||
|
||||
/** Failure-isolated character coin initialization, final-death loss and configuration records. */
|
||||
public final class CoinEventBridge {
|
||||
private final Consumer<EventInput> sink;private final Clock clock;
|
||||
public CoinEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
|
||||
public void initialized(CharacterRecord record,String source,CoinRules rules){var operation=stable(record.id(),"coins-initialized");var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("character_id",record.id().toString());data.put("character_name",record.name());data.put("source",source);data.put("configured_minimum",Long.toString(rules.startingMin()));data.put("configured_maximum",Long.toString(rules.startingMax()));data.put("balance",record.coins().toString());data.put("revision",record.revision().toString());data.put("outcome","acknowledged");emit(operation,"character.coins_initialized",data);}
|
||||
public void lost(CharacterRecord before,CharacterRecord after,UUID death,Instant diedAt){var data=new LinkedHashMap<String,Object>();data.put("operation_id",death.toString());data.put("death_id",death.toString());data.put("character_id",after.id().toString());data.put("character_name",after.name());data.put("reason","player_attributed_final_death");data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("death_bound_escrow",after.pvpCoinEscrow().toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("final_death_at",diedAt.toString());data.put("outcome","acknowledged");emit(death,"character.coins_lost",data);}
|
||||
public void configRead(String actorKind,UUID player,String name,String key,String value){config("config.read",actorKind,player,name,key,null,value);}
|
||||
public void configChanged(String actorKind,UUID player,String name,String key,String before,String after){config("config.changed",actorKind,player,name,key,before,after);}
|
||||
private void config(String type,String actorKind,UUID player,String name,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application",key.startsWith("starting-")?"future_character_initialization":"future_mob_defeats");data.put("outcome","acknowledged");try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),Map.copyOf(data)));}catch(RuntimeException ignored){}}
|
||||
private void emit(UUID action,String type,Map<String,Object> data){try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,null,Map.copyOf(data)));}catch(RuntimeException ignored){}}
|
||||
private static UUID stable(UUID character,String kind){return UUID.nameUUIDFromBytes(("fantasystick:coin:"+character+":"+kind).getBytes(StandardCharsets.UTF_8));}
|
||||
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:coin:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.CombatRewardRules;
|
||||
import games.dmg.fantasystick.progression.ManagedEncounterOrigin;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
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.util.function.Consumer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Failure-isolated, single-owner recipient resolution for combat XP and coin shares. */
|
||||
public final class CombatRewardEventBridge {
|
||||
public record Capture(UUID targetId,String targetType,String experienceClass,String coinClass,
|
||||
CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin,
|
||||
String stage,Instant defeatedAt,java.util.List<UUID> directContributors,
|
||||
String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool){
|
||||
public Capture{Objects.requireNonNull(targetId);Objects.requireNonNull(targetType);Objects.requireNonNull(experienceClass);Objects.requireNonNull(coinClass);Objects.requireNonNull(stage);Objects.requireNonNull(defeatedAt);directContributors=java.util.List.copyOf(directContributors);Objects.requireNonNull(rewardUnitId);Objects.requireNonNull(experiencePool);Objects.requireNonNull(coinPool);if(rewardUnitCount<1||experiencePool.signum()<0||coinPool.signum()<0)throw new IllegalArgumentException("Invalid combat reward capture");}
|
||||
}
|
||||
private final Consumer<EventInput> sink;private final Clock clock;
|
||||
public CombatRewardEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
|
||||
public void resolved(UUID operation,UUID character,BigInteger allocatedExperience,BigInteger allocatedCoins,
|
||||
CombatRewardRules.KillPosition restricted,CombatRewardRules rules,Capture capture,
|
||||
CharacterRecord before,CharacterStorage.CombatAward award,EquipmentSessions.Active active,
|
||||
Player player,String nativeSync,String feedback){
|
||||
if(award==null||award.record().isEmpty()||before==null)return;var after=award.record().orElseThrow();var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("reward_receipt",operation.toString());data.put("stage",capture.stage());data.put("recipient_character_id",character.toString());data.put("target_id",capture.targetId().toString());data.put("target_type",capture.targetType());data.put("experience_class",capture.experienceClass());data.put("coin_class",capture.coinClass());data.put("defeated_at",capture.defeatedAt().toString());data.put("direct_contributor_character_ids",capture.directContributors().stream().map(UUID::toString).sorted().toList());data.put("reward_unit_id",capture.rewardUnitId());data.put("reward_unit_count",capture.rewardUnitCount());data.put("experience_pool",capture.experiencePool().toString());data.put("coin_pool",capture.coinPool().toString());if(capture.killPosition()!=null)data.put("kill_location",position(capture.killPosition()));data.put("allocated_experience",allocatedExperience.toString());data.put("allocated_coins",allocatedCoins.toString());var appliedExperience=after.experience().subtract(before.experience()).max(BigInteger.ZERO);var appliedCoins=after.coins().subtract(before.coins()).max(BigInteger.ZERO);data.put("applied_experience",appliedExperience.toString());data.put("applied_coins",appliedCoins.toString());data.put("suppressed_experience",allocatedExperience.subtract(appliedExperience.min(allocatedExperience)).toString());data.put("suppressed_coins",allocatedCoins.subtract(appliedCoins.min(allocatedCoins)).toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("before_level",before.level());data.put("after_level",after.level());data.put("before_experience",before.experience().toString());data.put("after_experience",after.experience().toString());data.put("before_points",before.unspentPoints().toString());data.put("after_points",after.unspentPoints().toString());data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("storage_status",award.status().name().toLowerCase(java.util.Locale.ROOT));data.put("levels_gained",award.levels());data.put("native_sync",nativeSync);data.put("feedback",feedback);if(capture.managedOrigin()!=null){data.put("resolution","managed_origin_exempt");data.put("managed_origin_kind",capture.managedOrigin().kind().name().toLowerCase(java.util.Locale.ROOT));data.put("managed_origin_source",capture.managedOrigin().source().toString());data.put("spacing_applicability","authenticated_managed_origin_exempt");}else if(restricted!=null){boolean blocked=award.status()==CharacterStorage.CombatAwardStatus.SPACING_BLOCKED;data.put("resolution",blocked?"ordinary_spacing_blocked":"ordinary_spacing_eligible");data.put("spacing_applicability","ordinary_restricted_kill");data.put("spacing_minimum",rules.restrictedSpacing().toPlainString());var prior=before.restrictedKills().get(restricted.world());if(prior!=null)data.put("ordinary_history_before",position(prior));var current=after.restrictedKills().get(restricted.world());if(current!=null)data.put("ordinary_history_after",position(current));}else{data.put("resolution","spacing_not_applicable");data.put("spacing_applicability","passive_or_fantasy_character");}data.put("loss_reason",loss(award.status()));var context=context(active,player,after.name());try{sink.accept(new EventInput(id(operation),operation,clock.instant(),"combat.reward_resolved",context,Map.copyOf(data)));}catch(RuntimeException ignored){}
|
||||
}
|
||||
private EventInput.Context context(EquipmentSessions.Active active,Player player,String characterName){if(active==null||player==null)return null;var location=player.getLocation();if(location==null||location.getWorld()==null)return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),null);return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact"));}
|
||||
private static Map<String,Object> position(CombatRewardRules.KillPosition value){return Map.of("world_id",value.world().toString(),"x",value.x().toPlainString(),"y",value.y().toPlainString(),"z",value.z().toPlainString());}
|
||||
private static String loss(CharacterStorage.CombatAwardStatus status){return switch(status){case SPACING_BLOCKED->"restricted_kill_spacing";case AT_CAP->"maximum_level";case UNCHANGED->"zero_experience_and_coins";case DUPLICATE->"duplicate_receipt";case UNAVAILABLE->"recipient_unavailable";case STALE->"stale_retry";case APPLIED->"none";};}
|
||||
private static UUID id(UUID operation){return UUID.nameUUIDFromBytes(("fantasystick:combat-reward:"+operation).getBytes(StandardCharsets.UTF_8));}
|
||||
}
|
||||
@@ -113,6 +113,18 @@ public final class DownedEventBridge {
|
||||
"character.died", data);
|
||||
}
|
||||
|
||||
/** Physical return is a separate observed native result, never proof of final-death commitment or Tail delivery. */
|
||||
public void returnAttempted(DownedEpisode episode, String outcome) {
|
||||
if (episode.outcome() != DownedEpisode.Outcome.FINAL_DEATH) return;
|
||||
var data = common(episode);
|
||||
data.put("action_kind", "final_death_return");
|
||||
data.put("death_id", episode.id().toString());
|
||||
data.put("effective_death_time", episode.resolvedAt());
|
||||
data.put("stage", "native_return_attempt");
|
||||
data.put("outcome", outcome);
|
||||
emit(stable(episode.id(), "return"), episode.outcomeOperation(), clock.instant(), "action.resolved", data);
|
||||
}
|
||||
|
||||
public void outcomePublished(DownedEpisode episode) {
|
||||
if(episode.decision()!=null&&"phoenix_down".equals(episode.decision().source()))resolved(episode);
|
||||
var data = common(episode);
|
||||
@@ -122,8 +134,11 @@ public final class DownedEventBridge {
|
||||
data.put("outcome", "completed");
|
||||
emit(stable(episode.id(), "published"), episode.outcomeOperation(), episode.resolvedAt(),
|
||||
"action.resolved", data);
|
||||
if(episode.outcome()==DownedEpisode.Outcome.REVIVED&&episode.protectionUntil()!=null&&episode.decision()!=null&&"revive".equals(episode.decision().source()))protectionApplied(episode);
|
||||
}
|
||||
|
||||
private void protectionApplied(DownedEpisode episode){var data=common(episode);data.put("effect_id","revive_downed_invulnerability");data.put("application_id",stable(episode.id(),"revive-protection").toString());data.put("stage","native_application_acknowledged");data.put("outcome","applied");data.put("protection_until",episode.protectionUntil());data.put("end_policy","persisted_absolute_deadline");emit(stable(episode.id(),"protection-applied"),episode.outcomeOperation(),episode.resolvedAt(),"effect.applied",data);}
|
||||
|
||||
public void prevented(DownedEpisode episode, String reason, double amount) {
|
||||
if (!Double.isFinite(amount) || amount < 0) return;
|
||||
var now = clock.instant();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,12 @@ public final class EventCaptureListener implements Listener {
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock;
|
||||
private final Map<Object, Captured> captured = java.util.Collections.synchronizedMap(new WeakHashMap<>());
|
||||
private java.util.function.Predicate<org.bukkit.event.entity.EntityDamageEvent> ownedDamage = ignored -> false;
|
||||
|
||||
/** Feature owners replace the generic incoming view with one richer application record. */
|
||||
public void excludeOwnedDamage(java.util.function.Predicate<org.bukkit.event.entity.EntityDamageEvent> owner) {
|
||||
ownedDamage = ownedDamage.or(Objects.requireNonNull(owner));
|
||||
}
|
||||
|
||||
public EventCaptureListener(LocalEventLog events, EquipmentSessions sessions, Clock clock) {
|
||||
this.events = Objects.requireNonNull(events);
|
||||
@@ -97,6 +103,32 @@ public final class EventCaptureListener implements Listener {
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(org.bukkit.event.block.BlockDispenseArmorEvent event){resolveCaptured(event,event);}
|
||||
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(io.papermc.paper.event.player.PrePlayerAttackEntityEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PrePlayerAttackEntityEvent");details.put("target_id",event.getAttacked().getUniqueId().toString());details.put("will_attack",event.willAttack());details.putAll(item(event.getPlayer().getInventory().getItemInMainHand()));capture(event,event.getPlayer(),"attack_entity",details,event);}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(io.papermc.paper.event.player.PrePlayerAttackEntityEvent event){resolveCaptured(event,event);}
|
||||
/** Observe the final accepted native damage event once; no damage or health mutation occurs here. */
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)
|
||||
public void acceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
|
||||
if (!(event.getEntity() instanceof Player player) || !Double.isFinite(event.getFinalDamage())
|
||||
|| event.getFinalDamage() <= 0) return;
|
||||
try { if (ownedDamage.test(event)) return; }
|
||||
catch (RuntimeException ignored) { /* Observation failure cannot change native damage. */ }
|
||||
var snapshot = context(player).orElse(null);
|
||||
if (snapshot == null) return;
|
||||
var details = new LinkedHashMap<String, Object>();
|
||||
details.put("target_player_id", player.getUniqueId().toString());
|
||||
details.put("target_character_id", snapshot.characterId().toString());
|
||||
details.put("cause", event.getCause().name());
|
||||
details.put("amount", Double.toString(event.getFinalDamage()));
|
||||
details.put("outcome", "accepted_native_event");
|
||||
details.put("cancelled", false);
|
||||
if (event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent by) {
|
||||
details.put("source_entity_id", by.getDamager().getUniqueId().toString());
|
||||
details.put("source_entity_type", by.getDamager().getType().name());
|
||||
}
|
||||
try {
|
||||
var operation = UUID.randomUUID();
|
||||
events.emit(new EventInput(operation, operation, clock.instant(), "combat.damage", snapshot, details));
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerItemHeldEvent event){capture(event,event.getPlayer(),"held_slot",Map.of("entry_point","PlayerItemHeldEvent","previous_slot",event.getPreviousSlot(),"new_slot",event.getNewSlot()),event);}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerItemHeldEvent event){resolveCaptured(event,event);}
|
||||
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerSwapHandItemsEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerSwapHandItemsEvent");details.putAll(item(event.getOffHandItem()));capture(event,event.getPlayer(),"swap_hands",details,event);}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -337,7 +337,11 @@ public final class LocalEventLog implements AutoCloseable {
|
||||
data.put("session_id", context.sessionId().toString());
|
||||
}
|
||||
if (context.location() != null) data.put("location", location(context.location()));
|
||||
} else data.put("actor_kind", "system");
|
||||
} else {
|
||||
var claimed = data.get("actor_kind");
|
||||
data.put("actor_kind", "console".equals(claimed) || "command_block".equals(claimed)
|
||||
? claimed : "system");
|
||||
}
|
||||
var envelope = new LinkedHashMap<String, Object>();
|
||||
envelope.put("specversion", "1.0");
|
||||
envelope.put("id", input.id().toString());
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.presentation.CharacterPresentation;
|
||||
import io.papermc.paper.event.player.AsyncChatEvent;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
|
||||
|
||||
/** Observes supported character messages; no recipient/delivery claims or signed-body changes. */
|
||||
public final class MessageEventBridge implements Listener {
|
||||
private static final Set<String> PRIVATE = Set.of("msg", "message", "tell", "w", "whisper", "pm", "reply", "r");
|
||||
private final Function<UUID, CharacterPresentation.View> views;
|
||||
private final SessionEventBridge sessions;
|
||||
private final java.util.function.Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
|
||||
public MessageEventBridge(Function<UUID, CharacterPresentation.View> views, SessionEventBridge sessions,
|
||||
java.util.function.Consumer<EventInput> sink, Clock clock) {
|
||||
this.views=Objects.requireNonNull(views);this.sessions=Objects.requireNonNull(sessions);
|
||||
this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false)
|
||||
public void chat(AsyncChatEvent event) {
|
||||
try {
|
||||
var view=views.apply(event.getPlayer().getUniqueId());
|
||||
if(view==null)return;
|
||||
var context=sessions.lastKnown(view.holder(),view.session(),view.name());
|
||||
if(context==null)return;
|
||||
var data=new LinkedHashMap<String,Object>();
|
||||
data.put("channel","public");
|
||||
data.put("message",event.signedMessage().message());
|
||||
data.put("asynchronous",event.isAsynchronous());
|
||||
data.put("cancelled",event.isCancelled());
|
||||
data.put("outcome",event.isCancelled()?"cancelled":"observed");
|
||||
data.put("delivery","unverified");
|
||||
data.put("location_quality","last_native_session_capture");
|
||||
emit("character.chat",context,data);
|
||||
} catch(RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false)
|
||||
public void privateMessage(PlayerCommandPreprocessEvent event) {
|
||||
try {
|
||||
String command=event.getMessage();
|
||||
if(command==null||!command.startsWith("/"))return;
|
||||
int end=command.indexOf(' ');
|
||||
String label=(end<0?command.substring(1):command.substring(1,end)).toLowerCase(Locale.ROOT);
|
||||
label=label.substring(label.lastIndexOf(':')+1);
|
||||
if(!PRIVATE.contains(label))return;
|
||||
var view=views.apply(event.getPlayer().getUniqueId());
|
||||
if(view==null)return;
|
||||
var cached=sessions.lastKnown(view.holder(),view.session(),view.name());
|
||||
if(cached==null)return;
|
||||
var position=event.getPlayer().getLocation();
|
||||
var context=new EventInput.Context(cached.playerId(),cached.playerName(),view.character(),view.name(),view.session(),
|
||||
new EventInput.Location(position.getWorld().getUID(),position.getWorld().getName(),
|
||||
position.getX(),position.getY(),position.getZ(),clock.instant(),"native_capture"));
|
||||
boolean reply=label.equals("reply")||label.equals("r");
|
||||
String remainder=end<0?"":command.substring(end+1);
|
||||
var data=new LinkedHashMap<String,Object>();
|
||||
data.put("command",label);data.put("reply",reply);
|
||||
if(reply)data.put("message",remainder);
|
||||
else {
|
||||
int separator=remainder.indexOf(' ');
|
||||
if(!remainder.isEmpty())data.put("recipient_token",separator<0?remainder:remainder.substring(0,separator));
|
||||
data.put("message",separator<0?"":remainder.substring(separator+1));
|
||||
}
|
||||
data.put("cancelled",event.isCancelled());
|
||||
data.put("outcome",event.isCancelled()?"cancelled":"observed");
|
||||
data.put("delivery","unverified");
|
||||
emit("character.private_message",context,data);
|
||||
} catch(RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private void emit(String type,EventInput.Context context,java.util.Map<String,Object> data) {
|
||||
var action=UUID.randomUUID();
|
||||
sink.accept(new EventInput(action,action,clock.instant(),type,context,data));
|
||||
}
|
||||
}
|
||||
@@ -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,52 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.party.CharacterParty;
|
||||
import games.dmg.fantasystick.party.CharacterPartyDirectory;
|
||||
import games.dmg.fantasystick.party.PartyRegistrar;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
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.Player;
|
||||
|
||||
/** Failure-isolated Registrar and persistent character-party mutation records. */
|
||||
public final class PartyEventBridge {
|
||||
public record Action(UUID id,EventInput.Context context,PartyRegistrar registrar,String kind,UUID target,EventInput.Context targetContext,UUID invitation,
|
||||
CharacterParty before,BigInteger charge,String reason){
|
||||
public Action{Objects.requireNonNull(id);Objects.requireNonNull(context);Objects.requireNonNull(registrar);Objects.requireNonNull(kind);if(charge==null)charge=BigInteger.ZERO;if(reason==null)reason="requested";}
|
||||
public Action(UUID id,EventInput.Context context,PartyRegistrar registrar,String kind,UUID target,UUID invitation,CharacterParty before,BigInteger charge){this(id,context,registrar,kind,target,null,invitation,before,charge,"requested");}
|
||||
}
|
||||
private final Consumer<EventInput> sink;private final Clock clock;
|
||||
public PartyEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
|
||||
public Action begin(Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,String characterName,PartyRegistrar registrar,String kind,UUID target,EventInput.Context targetContext,UUID invitation,CharacterParty before,BigInteger charge,UUID operation,String reason){var action=new Action(operation==null?stableAction(active.character(),kind,target,invitation,before):operation,context(player,active,characterName),registrar,kind,target,targetContext,invitation,before,charge,reason);emitAction("action.observed",action,"observed",null,null,null);return action;}
|
||||
public void failed(Action action,String reason){emitAction("action.resolved",action,"failed",reason,null,null);}
|
||||
public void refused(Action action,String reason){emitAction("action.resolved",action,"refused",reason,null,null);}
|
||||
public void resolved(Action action,CharacterPartyDirectory.Result result){if(result==null||result.status()!=CharacterPartyDirectory.Status.APPLIED){refused(action,result==null?"unavailable":result.status().name().toLowerCase(java.util.Locale.ROOT));return;}resolved(action,result.party().orElse(null),result.invitation().orElse(null));}
|
||||
public void resolved(Action action,games.dmg.fantasystick.party.PartyManagement.Outcome outcome){if(outcome==null||outcome.status()!=games.dmg.fantasystick.party.PartyManagement.Status.APPLIED){refused(action,outcome==null?"unavailable":outcome.status().name().toLowerCase(java.util.Locale.ROOT));return;}var after=outcome.party().orElse(null);CharacterPartyDirectory.Offer offer=null;if(after!=null){if("invite".equals(action.kind()))offer=after.invitations().stream().filter(value->value.character().equals(action.target())&&(action.before()==null||action.before().invitations().stream().noneMatch(old->old.id().equals(value.id())))).findFirst().map(value->new CharacterPartyDirectory.Offer(value.id(),after.id(),value.character())).orElse(null);else if(action.invitation()!=null)offer=new CharacterPartyDirectory.Offer(action.invitation(),after.id(),action.target()==null?action.context().characterId():action.target());}resolved(action,after,offer);}
|
||||
private void resolved(Action action,CharacterParty after,CharacterPartyDirectory.Offer offer){emitAction("action.resolved",action,"accepted","acknowledged",after,offer);var businessAfter="disband".equals(action.kind())||"leader_leave".equals(action.kind())?null:after;emitAction(type(action.kind(),action.before(),businessAfter),action,"accepted","acknowledged",businessAfter,offer);}
|
||||
public void registrarPlaced(String actorKind,UUID actorPlayer,String actorName,PartyRegistrar registrar,UUID entity,boolean loaded){registrar("party_registrar.placed",actorKind,actorPlayer,actorName,registrar,entity,loaded,"registered_and_reconciled");}
|
||||
public void registrarRemoved(String actorKind,UUID actorPlayer,String actorName,PartyRegistrar registrar,UUID entity,boolean loaded){registrar("party_registrar.removed",actorKind,actorPlayer,actorName,registrar,entity,loaded,"registration_removed");}
|
||||
public void finalDefeat(UUID character,CharacterParty before,CharacterPartyDirectory.Defeat outcome){if(before==null||outcome==CharacterPartyDirectory.Defeat.UNCHANGED)return;var action=stable(before.id(),character,before.revision(),"final-death");var data=partyData(before,null);data.put("reason","final_death");data.put("subject_character_id",character.toString());data.put("popup_delivery",outcome==CharacterPartyDirectory.Defeat.PARTY_DISBANDED?"active_former_member_delivery_queued":"not_applicable");data.put("outcome","acknowledged");data.put("operation_id",action.toString());emit(id(action,outcome==CharacterPartyDirectory.Defeat.PARTY_DISBANDED?"party.disbanded":"party.left"),action,clock.instant(),outcome==CharacterPartyDirectory.Defeat.PARTY_DISBANDED?"party.disbanded":"party.left",null,data);}
|
||||
public void configRead(String actorKind,UUID player,String name,String key,String value){config("config.read",actorKind,player,name,key,null,value);}
|
||||
public void configChanged(String actorKind,UUID player,String name,String key,String before,String after){config("config.changed",actorKind,player,name,key,before,after);}
|
||||
|
||||
private void emitAction(String type,Action action,String outcome,String reason,CharacterParty after,CharacterPartyDirectory.Offer invitation){var data=partyData(action.before(),after);data.put("action_kind","party_"+action.kind());data.put("operation_id",action.id().toString());data.put("registrar_id",action.registrar().id().toString());data.put("registrar_location",registrarLocation(action.registrar()));data.put("actor_character_id",action.context().characterId().toString());data.put("actor_player_id",action.context().playerId().toString());data.put("actor_session_id",action.context().sessionId().toString());if(action.target()!=null)data.put("target_character_id",action.target().toString());if(action.targetContext()!=null){data.put("target_player_id",action.targetContext().playerId().toString());data.put("target_session_id",action.targetContext().sessionId().toString());if(action.targetContext().playerName()!=null)data.put("target_player_name",action.targetContext().playerName());if(action.targetContext().location()!=null)data.put("target_location",locationData(action.targetContext().location()));}if(action.invitation()!=null)data.put("invitation_id",action.invitation().toString());else if(invitation!=null)data.put("invitation_id",invitation.id().toString());if(action.charge().signum()>0){data.put("charged_coins",action.charge().toString());data.put("charge_receipt",action.id().toString());}data.put("reason",action.reason());data.put("popup_delivery",type.equals("party.disbanded")?"active_former_member_delivery_queued":"not_applicable");data.put("outcome",outcome);if(reason!=null)data.put(type.startsWith("party.")?"mutation_boundary":"reason",reason);emit(id(action.id(),type),action.id(),clock.instant(),type,action.context(),data);}
|
||||
private static String type(String kind,CharacterParty before,CharacterParty after){return switch(kind){case "create"->"party.created";case "invite"->"party.invitation_created";case "cancel"->"party.invitation_cancelled";case "decline"->"party.invitation_declined";case "accept"->"party.joined";case "kick"->"party.member_kicked";case "transfer"->"party.leadership_transferred";case "disband","leader_leave"->"party.disbanded";case "leave"->before!=null&&before.leader().equals(before.members().stream().filter(id->after==null||!after.members().contains(id)).findFirst().orElse(null))?"party.disbanded":"party.left";default->"party.mutated";};}
|
||||
private static LinkedHashMap<String,Object> partyData(CharacterParty before,CharacterParty after){var data=new LinkedHashMap<String,Object>();var party=after==null?before:after;if(party!=null){data.put("party_id",party.id().toString());data.put("party_display_name",party.displayName());data.put("party_canonical_name",party.canonicalName());}if(before!=null){data.put("before_revision",before.revision().toString());data.put("before_leader_character_id",before.leader().toString());data.put("before_member_character_ids",before.members().stream().map(UUID::toString).sorted().toList());}if(after!=null){data.put("after_revision",after.revision().toString());data.put("after_leader_character_id",after.leader().toString());data.put("after_member_character_ids",after.members().stream().map(UUID::toString).sorted().toList());}return data;}
|
||||
private void registrar(String type,String actorKind,UUID actorPlayer,String actorName,PartyRegistrar registrar,UUID entity,boolean loaded,String outcome){var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(actorPlayer!=null)data.put("actor_player_id",actorPlayer.toString());if(actorName!=null)data.put("actor_player_name",actorName);data.put("registrar_id",registrar.id().toString());data.put("registrar_name",registrar.name());data.put("registrar_revision","0");data.put("operation_id",registrar.id().toString());data.put("registered_location",registrarLocation(registrar));if(entity!=null)data.put("entity_id",entity.toString());data.put("entity_status",loaded?"loaded":"unloaded");data.put("outcome",outcome);emit(id(registrar.id(),type),registrar.id(),clock.instant(),type,actorPlayer==null?null:new EventInput.Context(actorPlayer,actorName,null,null,null,null),data);}
|
||||
private void config(String type,String actorKind,UUID player,String name,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application","future_party_transactions");data.put("outcome","acknowledged");emit(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),data);}
|
||||
private static Map<String,Object> locationData(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(),"quality",value.quality());}
|
||||
private static Map<String,Object> registrarLocation(PartyRegistrar value){return Map.of("world_id",value.world().toString(),"x",Double.toString(value.x()),"y",Double.toString(value.y()),"z",Double.toString(value.z()),"yaw",Float.toString(value.yaw()),"pitch",Float.toString(value.pitch()),"quality","registered_exact");}
|
||||
public EventInput.Context context(Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
|
||||
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 stableAction(UUID actor,String kind,UUID target,UUID invitation,CharacterParty before){String party=before==null?"none":before.id().toString(),revision=before==null?"none":before.revision().toString();return UUID.nameUUIDFromBytes(("fantasystick:party-action:"+actor+":"+kind+":"+party+":"+revision+":"+target+":"+invitation).getBytes(StandardCharsets.UTF_8));}
|
||||
private static UUID stable(UUID party,UUID character,BigInteger revision,String kind){return UUID.nameUUIDFromBytes(("fantasystick:party:"+party+":"+character+":"+revision+":"+kind).getBytes(StandardCharsets.UTF_8));}
|
||||
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:party:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
|
||||
private void emit(UUID id,UUID action,java.time.Instant time,String type,EventInput.Context context,Map<String,Object> data){try{sink.accept(new EventInput(id,action,time,type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}}
|
||||
}
|
||||
@@ -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,39 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.passive.PassiveDefinition;
|
||||
import games.dmg.fantasystick.passive.PassiveEngine;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
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;
|
||||
|
||||
/** Feature-owned acknowledged passive mutations and Quicker Regeneration configuration records. */
|
||||
public final class PassiveEventBridge {
|
||||
public record Action(UUID actionId,EventInput.Context context,String passive,boolean learn,
|
||||
PassiveDefinition definition,CharacterRecord before){
|
||||
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(passive);Objects.requireNonNull(definition);Objects.requireNonNull(before);}
|
||||
}
|
||||
private final Consumer<EventInput> sink;private final Clock clock;
|
||||
public PassiveEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
|
||||
public Action begin(ControlInput input,CharacterRecord before,PassiveDefinition definition,boolean learn){var action=new Action(UUID.randomUUID(),context(input.player(),input.session(),before.name()),definition.id(),learn,definition,before);emit("action.observed",action,"observed",null,null);return action;}
|
||||
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,PassiveDefinition definition,boolean learn,String reason){var action=new Action(UUID.randomUUID(),context(player,active,before.name()),definition.id(),learn,definition,before);emit("action.observed",action,"observed",null,null);emit("action.resolved",action,"refused",reason,null);}
|
||||
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null);}
|
||||
public void resolved(Action action,PassiveEngine.Mutation mutation){if(mutation!=null&&mutation.status()==games.dmg.fantasystick.persistence.CharacterStorage.PassiveMutationStatus.APPLIED&&mutation.record()!=null){emit("action.resolved",action,"accepted","acknowledged",mutation.record());emit(action.learn()?"passive.learned":"passive.unlearned",action,"accepted","acknowledged",mutation.record());}else emit("action.resolved",action,"refused",mutation==null?"unavailable":mutation.status().name().toLowerCase(java.util.Locale.ROOT),mutation==null?null:mutation.record());}
|
||||
public void configRead(String actorKind,UUID player,String playerName,String key,String value){config("config.read",actorKind,player,playerName,key,null,value);}
|
||||
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after){config("config.changed",actorKind,player,playerName,key,before,after);}
|
||||
|
||||
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after){var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"passive_learn":"passive_unlearn");data.put("passive_id",action.passive());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("point_cost",action.definition().pointCost().toString());var requirements=new LinkedHashMap<String,Object>();action.definition().requirements().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->requirements.put(entry.getKey().name(),entry.getValue().toString()));data.put("stat_prerequisites",requirements);data.put("passive_prerequisites",action.definition().prerequisites().stream().sorted().toList());data.put("outcome",outcome);if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());int slot=(action.learn()?after.passives():before.passives()).indexOf(action.passive());data.put("passive_slot",slot);data.put("slot_index_base",0);data.put("native_publication","acknowledged");data.put("owned_cleanup",action.learn()?"passive_state_acknowledged":"passive_state_acknowledged");}if(reason!=null)data.put(type.startsWith("passive.")?"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 config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(playerName!=null)data.put("actor_player_name",playerName);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application","future_recovery_pulses");data.put("outcome","acknowledged");var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}
|
||||
}
|
||||
private EventInput.Context context(org.bukkit.entity.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.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:passive:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
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.concurrent.CompletionStage;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Captured account preferences remain independent of gameplay and feedback delivery. */
|
||||
public final class PreferenceEventBridge {
|
||||
public record Action(UUID id, Instant time, EventInput.Context context, String key,
|
||||
boolean before, boolean savedBefore, Boolean requested, String actorKind) { }
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PlayerFeedbackPreferences preferences;
|
||||
private final Consumer<Throwable> failures;
|
||||
|
||||
public PreferenceEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
|
||||
EquipmentSessions sessions, PlayerFeedbackPreferences preferences,
|
||||
Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.preferences = Objects.requireNonNull(preferences);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public Action begin(Player player, String key, boolean before, Boolean requested) {
|
||||
Objects.requireNonNull(player);
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
var where = player.getLocation();
|
||||
EventInput.Location location = where == null || where.getWorld() == null ? null
|
||||
: new EventInput.Location(where.getWorld().getUID(), where.getWorld().getName(), where.getX(),
|
||||
where.getY(), where.getZ(), clock.instant(), "exact");
|
||||
var action = new Action(UUID.randomUUID(), clock.instant(), new EventInput.Context(player.getUniqueId(),
|
||||
player.getName(), record == null ? null : record.id(), record == null ? null : record.name(),
|
||||
record == null ? null : active.session(), location), key, before,
|
||||
key.equals("player-aura-particles") ? preferences.savedPlayerAurasEnabled(player.getUniqueId())
|
||||
: key.equals("combat_feedback") ? preferences.savedEnabled(player.getUniqueId())
|
||||
: preferences.savedAllParticlesEnabled(player.getUniqueId()), requested, "player");
|
||||
emit("action.observed", action, "observed", null, null, before, false);
|
||||
return action;
|
||||
}
|
||||
|
||||
public void refuseNonPlayer(org.bukkit.command.CommandSender sender, String key) {
|
||||
var kind = sender instanceof org.bukkit.command.ConsoleCommandSender ? "console"
|
||||
: sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "system";
|
||||
boolean initial = !key.equals("player-aura-particles");
|
||||
var action = new Action(UUID.randomUUID(), clock.instant(), null, key, initial, initial, null, kind);
|
||||
emit("action.observed", action, "observed", null, null, true, false);
|
||||
emit("action.resolved", action, "refused", "player_required", null, true, false);
|
||||
}
|
||||
|
||||
/** Written with the preference value in the same acknowledged file revision. */
|
||||
public PlayerFeedbackPreferences.EventIntent intent(Action action) {
|
||||
var fields = new LinkedHashMap<String,String>();
|
||||
fields.put("time", action.time().toString());
|
||||
fields.put("account", action.context().playerId().toString());
|
||||
fields.put("account_name", action.context().playerName());
|
||||
fields.put("key", action.key());
|
||||
fields.put("before", Boolean.toString(action.before()));
|
||||
fields.put("saved_before", Boolean.toString(action.savedBefore()));
|
||||
fields.put("requested", Boolean.toString(action.requested()));
|
||||
fields.put("all_enabled", Boolean.toString(preferences.allParticlesEnabled(action.context().playerId())));
|
||||
if (action.context().characterId() != null) {
|
||||
fields.put("character", action.context().characterId().toString());
|
||||
fields.put("character_name", action.context().characterName());
|
||||
fields.put("session", action.context().sessionId().toString());
|
||||
}
|
||||
var location = action.context().location();
|
||||
if (location != null) {
|
||||
fields.put("world", location.worldId().toString());
|
||||
fields.put("world_name", location.worldName());
|
||||
fields.put("x", Double.toString(location.x()));
|
||||
fields.put("y", Double.toString(location.y()));
|
||||
fields.put("z", Double.toString(location.z()));
|
||||
fields.put("location_time", location.capturedAt().toString());
|
||||
}
|
||||
return new PlayerFeedbackPreferences.EventIntent(action.id(), fields);
|
||||
}
|
||||
|
||||
public void replay(PlayerFeedbackPreferences.EventIntent intent) {
|
||||
try {
|
||||
var row = intent.captured();
|
||||
UUID account = UUID.fromString(row.get("account"));
|
||||
if (!"true".equals(row.get("published"))
|
||||
|| Boolean.parseBoolean(row.getOrDefault("saved_before", row.get("before")))
|
||||
== Boolean.parseBoolean(row.get("requested"))) return;
|
||||
UUID character = row.containsKey("character") ? UUID.fromString(row.get("character")) : null;
|
||||
EventInput.Location location = row.containsKey("world")
|
||||
? new EventInput.Location(UUID.fromString(row.get("world")), row.get("world_name"),
|
||||
Double.parseDouble(row.get("x")), Double.parseDouble(row.get("y")), Double.parseDouble(row.get("z")),
|
||||
Instant.parse(row.get("location_time")), "exact") : null;
|
||||
var context = new EventInput.Context(account, row.get("account_name"), character,
|
||||
row.get("character_name"), character == null ? null : UUID.fromString(row.get("session")), location);
|
||||
var action = new Action(intent.actionId(), Instant.parse(row.get("time")), context, row.get("key"),
|
||||
Boolean.parseBoolean(row.get("before")), Boolean.parseBoolean(
|
||||
row.getOrDefault("saved_before", row.get("before"))),
|
||||
Boolean.parseBoolean(row.get("requested")), "player");
|
||||
boolean after = Boolean.parseBoolean(row.get("requested"));
|
||||
boolean effective = row.get("key").equals("player-aura-particles")
|
||||
? after && Boolean.parseBoolean(row.get("all_enabled")) : after;
|
||||
emit("preference.changed", action, "accepted", "acknowledged", intent.sequence(), effective, true);
|
||||
} catch (RuntimeException ignored) { /* Invalid pending evidence cannot authorize a preference change. */ }
|
||||
}
|
||||
|
||||
public void read(Action action) {
|
||||
emit("action.resolved", action, "accepted", "read", null, action.before(), false);
|
||||
emit("preference.read", action, "accepted", "acknowledged", null, action.before(), false);
|
||||
}
|
||||
|
||||
public void saved(Action action, PlayerFeedbackPreferences.Update update, boolean published, boolean effective) {
|
||||
if (!published) {
|
||||
if (update.intent() != null) preferences.acknowledgeEvent(action.id());
|
||||
emit("action.resolved", action, "refused", "superseded", update.sequence(), effective, false);
|
||||
return;
|
||||
}
|
||||
if (update.intent() == null || action.savedBefore() == action.requested()) {
|
||||
if (update.intent() != null) preferences.acknowledgeEvent(action.id());
|
||||
emit("action.resolved", action, "refused", "unchanged", update.sequence(), effective, false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
preferences.markPublished(action.id()).whenComplete((ignored, failure) -> {
|
||||
if (failure != null) try { failures.accept(failure); } catch (RuntimeException ignoredFailure) { }
|
||||
});
|
||||
} catch (RuntimeException failure) {
|
||||
try { failures.accept(failure); } catch (RuntimeException ignoredFailure) { }
|
||||
}
|
||||
emit("action.resolved", action, "accepted", "acknowledged", update.sequence(), effective, false);
|
||||
emit("preference.changed", action, "accepted", "acknowledged", update.sequence(), effective, true);
|
||||
}
|
||||
|
||||
public void failed(Action action, String reason) {
|
||||
emit("action.resolved", action, "failed", reason, null, action.before(), false);
|
||||
}
|
||||
public void refused(Action action, String reason) {
|
||||
emit("action.resolved", action, "refused", reason, null, action.before(), false);
|
||||
}
|
||||
|
||||
private void emit(String type, Action action, String outcome, String reason, Long preferenceSequence,
|
||||
boolean effective, boolean acknowledgedIntent) {
|
||||
if (action == null) return;
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("action_kind", "preference_" + (action.requested() == null ? "read" : "change"));
|
||||
data.put("entry_point", action.key().equals("combat_feedback") ? "/fstick feedback"
|
||||
: "/fstick particles " + (action.key().equals("player-aura-particles") ? "players" : "all"));
|
||||
data.put("preference_key", action.key());
|
||||
if (action.context() == null) { data.put("actor_kind", action.actorKind()); data.put("location_status", "not_applicable"); }
|
||||
data.put("scope", "account");
|
||||
data.put("default_value", Boolean.toString(!action.key().equals("player-aura-particles")));
|
||||
data.put("before", Boolean.toString(action.before()));
|
||||
data.put("saved_before", Boolean.toString(action.savedBefore()));
|
||||
data.put("effective_before", Boolean.toString(action.before()));
|
||||
if (action.requested() != null) data.put("requested", Boolean.toString(action.requested()));
|
||||
if (action.context() != null) {
|
||||
boolean latestSaved = type.equals("preference.changed") ? action.requested()
|
||||
: preferenceSequence == null ? action.savedBefore()
|
||||
: action.key().equals("player-aura-particles")
|
||||
? preferences.savedPlayerAurasEnabled(action.context().playerId())
|
||||
: action.key().equals("combat_feedback")
|
||||
? preferences.savedEnabled(action.context().playerId())
|
||||
: preferences.savedAllParticlesEnabled(action.context().playerId());
|
||||
data.put("saved_value", Boolean.toString(latestSaved));
|
||||
data.put("effective_value", Boolean.toString(effective));
|
||||
data.put("effective_after", Boolean.toString(effective));
|
||||
}
|
||||
if (preferenceSequence != null) data.put("preference_sequence", Long.toString(preferenceSequence));
|
||||
data.put("outcome", outcome);
|
||||
if (reason != null) data.put(type.startsWith("preference.") ? "acknowledgment" : "reason", reason);
|
||||
if (type.equals("preference.changed")) {
|
||||
data.put("after", Boolean.toString(action.requested()));
|
||||
data.put("preference_revision", Long.toString(preferenceSequence));
|
||||
}
|
||||
try {
|
||||
var input = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:preference:"+action.id()+":"+type)
|
||||
.getBytes(StandardCharsets.UTF_8)), action.id(), type.equals("preference.changed") ? action.time()
|
||||
: clock.instant(), type, action.context(), Map.copyOf(data));
|
||||
var result = sink.apply(input);
|
||||
if (acknowledgedIntent) result.whenComplete((delivery, failure) -> {
|
||||
if (failure == null && delivery != LocalEventLog.Delivery.REJECTED)
|
||||
preferences.acknowledgeEvent(action.id());
|
||||
});
|
||||
} catch (RuntimeException ignored) { /* Logging cannot affect the command or persistent state. */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
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;
|
||||
|
||||
/** Feature-owned permanent-stat outcomes, isolated from purchase and native-health authority. */
|
||||
public final class ProgressionEventBridge {
|
||||
public record Action(UUID actionId, EventInput.Context context, Stat stat, BigInteger expectedRevision,
|
||||
BigInteger statBefore, BigInteger pointsBefore, BigDecimal healthBefore,
|
||||
BigDecimal maximumBefore, BigDecimal nativeLimit) {
|
||||
public Action {
|
||||
Objects.requireNonNull(actionId); Objects.requireNonNull(context); Objects.requireNonNull(stat);
|
||||
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(statBefore); Objects.requireNonNull(pointsBefore);
|
||||
Objects.requireNonNull(healthBefore); Objects.requireNonNull(maximumBefore); Objects.requireNonNull(nativeLimit);
|
||||
}
|
||||
}
|
||||
|
||||
private final Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
|
||||
public ProgressionEventBridge(Consumer<EventInput> sink, Clock clock) {
|
||||
this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
public Action begin(ControlInput input, CharacterRecord before, Stat stat, BigDecimal health,
|
||||
BigDecimal maximum, BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(input);Objects.requireNonNull(before);Objects.requireNonNull(stat);
|
||||
var action=new Action(UUID.randomUUID(),context(input,before.name()),stat,before.revision(),
|
||||
before.stats().get(stat),before.unspentPoints(),health,maximum.min(nativeLimit),nativeLimit);
|
||||
emit("action.observed",action,"observed",null,null,null,null);
|
||||
return action;
|
||||
}
|
||||
|
||||
public void refused(Action action,String reason,CharacterRecord current){
|
||||
emit("action.resolved",action,"refused",reason,current,null,null);
|
||||
}
|
||||
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null,null);}
|
||||
public void increased(Action action,CharacterRecord saved,String nativePublication,BigDecimal nativeHealthAfter){
|
||||
emit("action.resolved",action,"accepted","acknowledged",saved,nativePublication,nativeHealthAfter);
|
||||
emit("character.stat_increased",action,"accepted","acknowledged",saved,nativePublication,nativeHealthAfter);
|
||||
}
|
||||
|
||||
public void refused(org.bukkit.entity.Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,
|
||||
CharacterRecord current,Stat stat,BigDecimal nativeLimit,String reason){
|
||||
var input=new ControlInput(player,active,0,"",ControlInput.Kind.BOOK,ControlInput.Gesture.USE,null,0,0,()->true);
|
||||
var action=begin(input,current,stat,BigDecimal.valueOf(player.getHealth()),current.maximumHealth().min(nativeLimit),nativeLimit);
|
||||
emit("action.resolved",action,"refused",reason,current,null,null);
|
||||
}
|
||||
|
||||
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after,
|
||||
String nativePublication,BigDecimal nativeHealthAfter){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("action_kind","character_stat_increase");
|
||||
data.put("stat_id",action.stat().name());data.put("expected_revision",action.expectedRevision().toString());
|
||||
data.put("before_revision",action.expectedRevision().toString());data.put("stat_before",action.statBefore().toString());
|
||||
data.put("points_before",action.pointsBefore().toString());data.put("health_before",action.healthBefore().toPlainString());
|
||||
data.put("maximum_health_before",action.maximumBefore().toPlainString());data.put("native_health_ceiling",action.nativeLimit().toPlainString());
|
||||
data.put("outcome",outcome);if(reason!=null)data.put(type.equals("character.stat_increased")?"native_boundary":"reason",reason);
|
||||
if(after!=null){data.put("after_revision",after.revision().toString());data.put("stat_after",after.stats().get(action.stat()).toString());data.put("points_after",after.unspentPoints().toString());data.put("health_after",(nativeHealthAfter==null?after.health():nativeHealthAfter).toPlainString());data.put("maximum_health_after",after.maximumHealth().min(action.nativeLimit()).toPlainString());}
|
||||
if(nativePublication!=null)data.put("native_publication",nativePublication);
|
||||
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(ControlInput input,String characterName){var player=input.player();return new EventInput.Context(player.getUniqueId(),player.getName(),input.session().character(),characterName,input.session().session(),location(player.getLocation()));}
|
||||
private EventInput.Location location(Location location){return new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact");}
|
||||
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:progression:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
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.Player;
|
||||
|
||||
/** One authoritative out-of-combat healing publication with bounded passive attribution. */
|
||||
public final class RecoveryEventBridge {
|
||||
public record Action(UUID actionId,EventInput.Context context,BigInteger expectedRevision,
|
||||
BigDecimal healthBefore,BigDecimal proposed,BigInteger configuredBase,
|
||||
BigInteger configuredQuickerRegeneration){
|
||||
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(healthBefore);Objects.requireNonNull(proposed);Objects.requireNonNull(configuredBase);Objects.requireNonNull(configuredQuickerRegeneration);}
|
||||
}
|
||||
private final Consumer<EventInput> sink;private final Clock clock;
|
||||
public RecoveryEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
|
||||
public Action begin(Player player,EquipmentSessions.Active active,CharacterRecord record,double current,double proposed,BigInteger base,BigInteger bonus){var action=new Action(UUID.randomUUID(),context(player,active,record.name()),record.revision(),BigDecimal.valueOf(current),BigDecimal.valueOf(proposed),base,bonus);emit("action.observed",action,"observed",null,null,null,null,null);return action;}
|
||||
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(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){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,40 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.ability.AbilityExecution;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
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.Player;
|
||||
|
||||
/** Learned Revive Tail-branch cast and consume-once restoration evidence. */
|
||||
public final class ReviveEventBridge {
|
||||
public record Cast(UUID actionId,EventInput.Context caster,UUID casterCharacter,String casterCharacterName,
|
||||
UUID targetPlayer,String targetPlayerName,Map<String,Object> targetLocation,
|
||||
Instant castAt,Instant cooldownUntil,double range){
|
||||
public Cast{Objects.requireNonNull(actionId);Objects.requireNonNull(caster);Objects.requireNonNull(casterCharacter);Objects.requireNonNull(casterCharacterName);Objects.requireNonNull(targetPlayer);Objects.requireNonNull(targetPlayerName);Objects.requireNonNull(targetLocation);Objects.requireNonNull(castAt);Objects.requireNonNull(cooldownUntil);}
|
||||
}
|
||||
private final Consumer<EventInput> sink;private final Clock clock;
|
||||
public ReviveEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
|
||||
public Cast cast(AbilityExecution execution,Player target){var input=execution.input();var caster=execution.character();var now=clock.instant();var action=new Cast(UUID.randomUUID(),context(input.player(),input.session(),caster.name()),caster.id(),caster.name(),target.getUniqueId(),target.getName(),locationData(target.getLocation(),now),now,caster.cooldowns().get("revive"),input.player().getLocation().distance(target.getLocation()));emitAbility("ability.cast",action,"accepted","cooldown_committed",null);return action;}
|
||||
public void resolved(Cast cast,String outcome,String stage){emitAbility("ability.resolved",cast,outcome,stage,null);}
|
||||
public void failed(Cast cast,String reason){emitAbility("ability.resolved",cast,"failed","tail_restoration",reason);}
|
||||
public void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Player carrier){revived(cast,reservation,dead,restored,carrier,false);}
|
||||
public void recovered(RevivalStore.Reservation reservation,CharacterRecord restored,Player carrier){if(reservation.administrative())return;revived(null,reservation,null,restored,carrier,true);}
|
||||
|
||||
private void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Player carrier,boolean recovery){var data=new LinkedHashMap<String,Object>();data.put("revival_kind","learned_tail");data.put("selected_branch","tail");data.put("reservation_id",reservation.id().toString());data.put("death_id",reservation.death().toString());data.put("tail_id",reservation.death().toString());data.put("caster_character_id",reservation.casterCharacter().toString());if(cast!=null){data.put("caster_player_id",cast.caster().playerId().toString());data.put("caster_player_name",cast.caster().playerName());data.put("cast_action_id",cast.actionId().toString());}data.put("carrier_player_id",carrier.getUniqueId().toString());data.put("carrier_player_name",carrier.getName());data.put("source_slot",reservation.sourceSlot());data.put("destination_slot",reservation.sourceAmount()==1?reservation.sourceSlot():reservation.destinationSlot());data.put("source_quantity_before",Integer.toString(reservation.sourceAmount()));data.put("source_quantity_after",Integer.toString(reservation.sourceAmount()-1));data.put("consumed_quantity","1");data.put("restored_character_id",restored.id().toString());data.put("restored_character_name",restored.name());data.put("restored_revision",restored.revision().toString());data.put("restored_health",restored.health().toPlainString());data.put("maximum_health",restored.maximumHealth().toPlainString());data.put("no_auto_entry",true);data.put("recovery_reconciliation",recovery);data.put("outcome","acknowledged");data.put("carrier_location",locationData(carrier.getLocation(),clock.instant()));var context=new EventInput.Context(carrier.getUniqueId(),carrier.getName(),null,null,null,location(carrier.getLocation()));emit(id(reservation.id(),"character-revived"),reservation.id(),reservation.createdAt(),"character.revived",context,data);if(cast!=null)emitAbility("ability.resolved",cast,"restored","tail_restoration_acknowledged",null,Map.of("reservation_id",reservation.id().toString(),"death_id",reservation.death().toString(),"restored_character_id",restored.id().toString()));}
|
||||
private void emitAbility(String type,Cast cast,String outcome,String stage,String reason){emitAbility(type,cast,outcome,stage,reason,Map.of());}
|
||||
private void emitAbility(String type,Cast cast,String outcome,String stage,String reason,Map<String,Object> extra){var data=new LinkedHashMap<String,Object>();data.put("ability_id","revive");data.put("selected_branch","tail");data.put("stage",stage);data.put("outcome",outcome);data.put("cooldown_until",cast.cooldownUntil());data.put("caster_character_id",cast.casterCharacter().toString());data.put("target_player_id",cast.targetPlayer().toString());data.put("target_player_name",cast.targetPlayerName());data.put("target_location",cast.targetLocation());data.put("final_range",Double.toString(cast.range()));data.put("line_of_sight",true);if(reason!=null)data.put("reason",reason);data.putAll(extra);emit(id(cast.actionId(),type),cast.actionId(),cast.castAt(),type,cast.caster(),data);}
|
||||
private EventInput.Context context(Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
|
||||
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 Map<String,Object> locationData(Location value,Instant at){return Map.of("world_id",value.getWorld().getUID().toString(),"world_name",value.getWorld().getName(),"x",Double.toString(value.getX()),"y",Double.toString(value.getY()),"z",Double.toString(value.getZ()),"captured_at",at,"quality","exact");}
|
||||
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:revive:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
|
||||
private void emit(UUID id,UUID action,Instant time,String type,EventInput.Context context,Map<String,Object> data){try{sink.accept(new EventInput(id,action,time,type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Observes acknowledged session boundaries; the log never authorizes a transition. */
|
||||
public final class SessionEventBridge {
|
||||
private final Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
private final java.util.concurrent.ConcurrentHashMap<UUID, EventInput.Context> current = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
public SessionEventBridge(Consumer<EventInput> sink, Clock clock) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
public void entered(Player player, UUID session, CharacterRecord record, Location entry,
|
||||
boolean resumedDowned, boolean adventureMode) {
|
||||
try {
|
||||
var now = clock.instant();
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("session_id", session.toString());
|
||||
data.put("character_id", record.id().toString());
|
||||
data.put("revision", record.revision().toString());
|
||||
data.put("entry_location", locationMap(entry));
|
||||
data.put("resumed_downed", resumedDowned);
|
||||
data.put("adventure_mode_applied", adventureMode);
|
||||
data.put("equipment_outcome", "packed");
|
||||
data.put("session_receipt", "acknowledged");
|
||||
data.put("outcome", "active");
|
||||
var context = new EventInput.Context(player.getUniqueId(), player.getName(), record.id(), record.name(),
|
||||
session, location(player.getLocation()));
|
||||
current.put(context.playerId(), context);
|
||||
sink.accept(new EventInput(id(session, "entered"), session, now, "character.entered", context, data));
|
||||
} catch (RuntimeException ignored) {
|
||||
// An unavailable journal cannot change the acknowledged session.
|
||||
}
|
||||
}
|
||||
|
||||
/** Thread-safe last native capture, never a live Bukkit lookup from asynchronous chat. */
|
||||
public EventInput.Context lastKnown(UUID player, UUID session, String characterName) {
|
||||
var value = current.get(player);
|
||||
if (value == null || !value.sessionId().equals(session)) return null;
|
||||
return new EventInput.Context(value.playerId(), value.playerName(), value.characterId(), characterName,
|
||||
session, value.location());
|
||||
}
|
||||
|
||||
public void forget(UUID player, UUID session) {
|
||||
var value=current.get(player);
|
||||
if(value!=null&&value.sessionId().equals(session))current.remove(player,value);
|
||||
}
|
||||
|
||||
public record ExitCapture(EventInput.Context context, java.util.Map<String, Object> entryLocation, String revision) { }
|
||||
|
||||
public ExitCapture captureExit(Player player, UUID session, CharacterRecord record, Location entry) {
|
||||
try {
|
||||
return new ExitCapture(new EventInput.Context(player.getUniqueId(), player.getName(), record.id(),
|
||||
record.name(), session, location(player.getLocation())), locationMap(entry), record.revision().toString());
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void exited(UUID session, ExitCapture capture) {
|
||||
if (capture == null) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("session_id", session.toString());
|
||||
data.put("character_id", capture.context().characterId().toString());
|
||||
data.put("revision", capture.revision());
|
||||
data.put("entry_location", capture.entryLocation());
|
||||
data.put("reason", "living_exit");
|
||||
data.put("equipment_outcome", "restored");
|
||||
data.put("session_receipt", "completed");
|
||||
data.put("outcome", "acknowledged");
|
||||
forget(capture.context().playerId(),session);
|
||||
sink.accept(new EventInput(id(session, "exited"), session, clock.instant(), "character.exited", capture.context(), data));
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private java.util.Map<String, Object> locationMap(Location value) {
|
||||
return java.util.Map.of("world_id", value.getWorld().getUID().toString(), "world_name", value.getWorld().getName(),
|
||||
"x", value.getX(), "y", value.getY(), "z", value.getZ(), "captured_at", clock.instant().toString());
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(),
|
||||
value.getY(), value.getZ(), clock.instant(), "native_capture");
|
||||
}
|
||||
|
||||
private static UUID id(UUID session, String stage) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:session:" + session + ':' + stage).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,110 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.SwitchDelaySettings;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.BlockCommandSender;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Observes the registered administrator's acknowledged switch policy; never changes deadlines. */
|
||||
public final class SwitchConfigEventBridge implements AutoCloseable {
|
||||
public record Actor(String kind, EventInput.Context context) { }
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final EquipmentSessions sessions;
|
||||
private final FeatureEventOutbox outbox;
|
||||
|
||||
public SwitchConfigEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, EquipmentSessions sessions,
|
||||
Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
outbox = new FeatureEventOutbox(data, "switch-config-event-intents", java.util.Set.of("config.changed"),
|
||||
sink, failures);
|
||||
}
|
||||
|
||||
public Actor actor(CommandSender sender) {
|
||||
try {
|
||||
var player = sender instanceof Player value ? value : null;
|
||||
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
|
||||
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
|
||||
player.getName(), record == null ? null : record.id(),
|
||||
record == null ? null : record.name(), record == null ? null : active.session(),
|
||||
safeLocation(player)));
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public void read(Actor actor, String effective) {
|
||||
if (actor == null) return;
|
||||
var data = common(actor);
|
||||
data.put("effective", effective);
|
||||
data.put("outcome", "acknowledged_read");
|
||||
var action = UUID.randomUUID();
|
||||
try { sink.apply(new EventInput(id(action, "config.read"), action, clock.instant(),
|
||||
"config.read", actor.context(), data)); }
|
||||
catch (RuntimeException ignored) { /* Observation cannot change the policy. */ }
|
||||
}
|
||||
|
||||
public void changed(Actor actor, UUID operation, String before, String effective,
|
||||
SwitchDelaySettings.Update saved) {
|
||||
if (actor == null) return;
|
||||
try {
|
||||
var data = common(actor);
|
||||
data.put("operation_id", operation.toString());
|
||||
data.put("store_sequence", Long.toString(saved.sequence()));
|
||||
data.put("before", before);
|
||||
data.put("after", saved.value());
|
||||
data.put("effective", effective);
|
||||
data.put("zero_disabled", saved.delay().isZero());
|
||||
data.put("outcome", "acknowledged");
|
||||
outbox.accepted(new EventInput(id(operation, "config.changed"), operation, clock.instant(),
|
||||
"config.changed", actor.context(), data));
|
||||
} catch (RuntimeException ignored) { /* A failed audit cannot roll back an effective setting. */ }
|
||||
}
|
||||
|
||||
private static Map<String, Object> common(Actor actor) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("key", SwitchDelaySettings.KEY);
|
||||
data.put("default", SwitchDelaySettings.DEFAULT);
|
||||
data.put("application_scope", "future_exit");
|
||||
data.put("existing_deadlines_unchanged", true);
|
||||
data.put("actor_kind", actor.kind());
|
||||
if (actor.context() == null) data.put("location_status", "not_applicable");
|
||||
else if (actor.context().location() == null) data.put("location_status", "unavailable");
|
||||
return data;
|
||||
}
|
||||
|
||||
private EventInput.Location safeLocation(Player player) {
|
||||
try {
|
||||
Location location = player.getLocation();
|
||||
if (location == null || location.getWorld() == null) return null;
|
||||
var world = location.getWorld();
|
||||
return new EventInput.Location(world.getUID(), world.getName(), location.getX(),
|
||||
location.getY(), location.getZ(), clock.instant(), "exact");
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private static UUID id(UUID action, String kind) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:switch-config:" + action + ":" + kind)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -79,6 +79,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
private java.util.function.Predicate<org.bukkit.entity.Entity> excluded = ignored -> false;
|
||||
private java.util.function.Function<LivingEntity,java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin>> managedOrigins=ignored->java.util.Optional.empty();
|
||||
private Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> managedSettlement=ignored->{};
|
||||
private games.dmg.fantasystick.events.CombatRewardEventBridge events;
|
||||
private boolean closed;
|
||||
|
||||
private final class PendingAward {
|
||||
@@ -88,12 +89,14 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
final BigInteger coins;
|
||||
final CombatRewardRules.KillPosition restricted;
|
||||
final CombatRewardRules rewardRules;
|
||||
BigInteger expectedRevision;
|
||||
final games.dmg.fantasystick.events.CombatRewardEventBridge.Capture capture;
|
||||
BigInteger expectedRevision;CharacterRecord before;
|
||||
boolean running;final CompletableFuture<Void> settled=new CompletableFuture<>();
|
||||
PendingAward(UUID id, UUID character, BigInteger experience, BigInteger coins,
|
||||
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules) {
|
||||
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules,
|
||||
games.dmg.fantasystick.events.CombatRewardEventBridge.Capture capture) {
|
||||
this.id = id; this.character = character; this.experience = experience;
|
||||
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;
|
||||
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;this.capture=Objects.requireNonNull(capture);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +143,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
if(closed)throw new IllegalStateException("Combat experience is closed");managedOrigins=Objects.requireNonNull(classifier);
|
||||
}
|
||||
public void attachManagedSettlement(Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> settlement){if(closed)throw new IllegalStateException("Combat experience is closed");managedSettlement=Objects.requireNonNull(settlement);}
|
||||
public void attachEvents(games.dmg.fantasystick.events.CombatRewardEventBridge value){if(closed||events!=null)throw new IllegalStateException("Combat reward events already attached or combat is closed");events=Objects.requireNonNull(value);}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
if (closed || retryTask != null) throw new IllegalStateException("Combat experience is already installed or closed");
|
||||
@@ -207,12 +211,12 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
|
||||
/** Freeze final-time eligibility and exact shares before the durable death claim. */
|
||||
public List<games.dmg.fantasystick.session.DownedEpisode.Reward> allocateDownedDefeat(games.dmg.fantasystick.session.DownedEpisode episode){
|
||||
var attribution=episode.attribution();if(!attribution.playerDefeat()||attribution.contributors().isEmpty())return List.of();var units=rewardUnits(new ArrayList<>(attribution.contributors()));if(units.isEmpty())return List.of();var capturedRewards=rewards;var replaySafeRandom=new java.util.Random(episode.id().getMostSignificantBits()^episode.id().getLeastSignificantBits());var xp=games.dmg.fantasystick.progression.RewardAllocator.allocate(capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,attribution.victimLevel()),units,replaySafeRandom);var coins=games.dmg.fantasystick.progression.RewardAllocator.allocate(attribution.victimCoins(),units,replaySafeRandom);return xp.keySet().stream().sorted().map(character->new games.dmg.fantasystick.session.DownedEpisode.Reward(character,xp.get(character),coins.get(character))).toList();
|
||||
var attribution=episode.attribution();if(!attribution.playerDefeat()||attribution.contributors().isEmpty())return List.of();var units=rewardUnits(new ArrayList<>(attribution.contributors()));if(units.isEmpty())return List.of();var capturedRewards=rewards;var replaySafeRandom=new java.util.Random(episode.id().getMostSignificantBits()^episode.id().getLeastSignificantBits());var experiencePool=capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,attribution.victimLevel());var xp=games.dmg.fantasystick.progression.RewardAllocator.allocate(experiencePool,units,replaySafeRandom);var coins=games.dmg.fantasystick.progression.RewardAllocator.allocate(attribution.victimCoins(),units,replaySafeRandom);return xp.keySet().stream().sorted().map(character->{var unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();return new games.dmg.fantasystick.session.DownedEpisode.Reward(character,xp.get(character),coins.get(character),String.valueOf(unit.key()),units.size());}).toList();
|
||||
}
|
||||
|
||||
/** Apply only the shares frozen in the winning durable final-death claim. */
|
||||
public CompletionStage<Void> settleDownedDefeat(games.dmg.fantasystick.session.DownedEpisode episode){
|
||||
if(episode.finalRewards().isEmpty())return CompletableFuture.completedStage(null);var capturedRewards=rewards;var settlements=new ArrayList<CompletableFuture<Void>>();for(var reward:episode.finalRewards()){UUID receipt=UUID.nameUUIDFromBytes(("downed:"+episode.id()+":"+reward.character()).getBytes(java.nio.charset.StandardCharsets.UTF_8));var proposed=new PendingAward(receipt,reward.character(),reward.experience(),reward.coins(),null,capturedRewards);var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);}return CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new));
|
||||
if(episode.finalRewards().isEmpty())return CompletableFuture.completedStage(null);var capturedRewards=rewards;var settlements=new ArrayList<CompletableFuture<Void>>();for(var reward:episode.finalRewards()){UUID receipt=UUID.nameUUIDFromBytes(("downed:"+episode.id()+":"+reward.character()).getBytes(java.nio.charset.StandardCharsets.UTF_8));var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(episode.player(),"player","fantasy_character","pvp_balance",null,null,"final_death",episode.resolvedAt(),episode.attribution().contributors(),reward.unitId(),reward.unitCount(),capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,episode.attribution().victimLevel()),episode.attribution().victimCoins());var proposed=new PendingAward(receipt,reward.character(),reward.experience(),reward.coins(),null,capturedRewards,capture);var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);}return CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@@ -240,7 +244,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
? coinRules.get().mob(coinTarget(target), random) : defeatedCoins;
|
||||
if (units.isEmpty()) {managed.ifPresent(this::settled);return;} // The defeat transition has already persisted any PvP balance loss.
|
||||
var capturedRewards = rewards;
|
||||
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(capturedRewards.pool(kind, level), units, random);
|
||||
var experiencePool=capturedRewards.pool(kind,level);
|
||||
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(experiencePool, units, random);
|
||||
var coins = games.dmg.fantasystick.progression.RewardAllocator.allocate(coinPool, units, random);
|
||||
CombatRewardRules.KillPosition restricted = managed.isPresent()||kind == CombatRewardRules.Target.PASSIVE
|
||||
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
|
||||
@@ -250,7 +255,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
for (UUID character : xp.keySet()) {
|
||||
String receiptAuthority=managed.isPresent()?authority:targetReward+":"+now+":"+authority;
|
||||
UUID receipt = UUID.nameUUIDFromBytes((receiptAuthority+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
var proposed = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,capturedRewards);
|
||||
var unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(target.getUniqueId(),target.getType().name().toLowerCase(java.util.Locale.ROOT),kind.name().toLowerCase(java.util.Locale.ROOT),coinTarget(target).name().toLowerCase(java.util.Locale.ROOT),defeatedCharacter==null?position(target.getLocation()):null,managed.orElse(null),defeatedCharacter==null?"mob_defeat":"fantasy_character_defeat",now,direct,String.valueOf(unit.key()),units.size(),experiencePool,coinPool);
|
||||
var proposed = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,capturedRewards,capture);
|
||||
var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);
|
||||
}
|
||||
managed.ifPresent(origin->CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new)).thenRun(()->settled(origin)));
|
||||
@@ -304,7 +310,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
synchronized (operation) { operation.running = false; }
|
||||
if (failure != null) { fail(operation, failure); return; }
|
||||
if (record.isEmpty() || record.orElseThrow().dead()) { complete(operation); return; }
|
||||
operation.expectedRevision = record.orElseThrow().revision();
|
||||
operation.before=record.orElseThrow();operation.expectedRevision = operation.before.revision();
|
||||
attempt(operation);
|
||||
});
|
||||
return;
|
||||
@@ -314,7 +320,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
synchronized (operation) { operation.running = false; }
|
||||
if (failure != null) { fail(operation, failure); return; }
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
|
||||
operation.expectedRevision = award.record().orElseThrow().revision(); attempt(operation); return;
|
||||
operation.before=award.record().orElseThrow();operation.expectedRevision = operation.before.revision(); attempt(operation); return;
|
||||
}
|
||||
complete(operation);
|
||||
if (award.record().isEmpty() || award.status() == CharacterStorage.CombatAwardStatus.UNAVAILABLE) return;
|
||||
@@ -344,12 +350,9 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
if (closed) return;
|
||||
CharacterRecord saved = award.record().orElseThrow();
|
||||
var active = sessions.activeCharacter(operation.character).orElse(null);
|
||||
if (active != null && !sessions.acknowledgeProgress(active, saved.revision().subtract(BigInteger.ONE), saved)
|
||||
&& !sessions.currentRecord(active).filter(saved::equals).isPresent()) {
|
||||
report(operation.character, new IllegalStateException("Saved combat progress could not synchronize with active session"));
|
||||
return;
|
||||
}
|
||||
Player player = active == null ? null : players.apply(active.player());
|
||||
boolean synchronizedSession=active==null||sessions.acknowledgeProgress(active,saved.revision().subtract(BigInteger.ONE),saved)||sessions.currentRecord(active).filter(saved::equals).isPresent();
|
||||
Player player=active==null?null:players.apply(active.player());String feedback=feedback(operation,award,player,synchronizedSession);var bridge=events;if(bridge!=null)bridge.resolved(operation.id,operation.character,operation.experience,operation.coins,operation.restricted,operation.rewardRules,operation.capture,operation.before,award,active,player,synchronizedSession?active==null?"not_applicable_offline":"acknowledged":"failed",feedback);
|
||||
if(!synchronizedSession){report(operation.character,new IllegalStateException("Saved combat progress could not synchronize with active session"));return;}
|
||||
if (player == null || !player.isOnline()) return;
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.APPLIED) {
|
||||
if (operation.experience.signum() > 0) player.sendMessage("Earned " + operation.experience + " fantasy XP.");
|
||||
@@ -373,6 +376,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private static String feedback(PendingAward operation,CharacterStorage.CombatAward award,Player player,boolean synchronizedSession){if(!synchronizedSession)return "suppressed_native_sync_failed";if(player==null||!player.isOnline())return "not_delivered_offline";return switch(award.status()){case APPLIED->operation.experience.signum()>0&&operation.coins.signum()>0?"experience_and_coins":operation.experience.signum()>0?"experience":operation.coins.signum()>0?"coins":"none";case SPACING_BLOCKED->"spacing_refusal";case AT_CAP->operation.coins.signum()>0?"maximum_level_and_coins":"maximum_level";case UNCHANGED->"zero_reward";case DUPLICATE->"none_duplicate";case STALE,UNAVAILABLE->"none_unavailable";};}
|
||||
|
||||
public void configure(CombatRewardRules updated) {
|
||||
if (closed) throw new IllegalStateException("Combat rewards are closed");
|
||||
rewards = Objects.requireNonNull(updated);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,25 @@ import org.bukkit.entity.Player;
|
||||
|
||||
/** Registrar-authorized mutations with durable operation plans spanning coin and party files. */
|
||||
public final class PartyManagement implements AutoCloseable {
|
||||
public enum Status{APPLIED,INVALID_SESSION,TOO_FAR,INVALID_NAME,ALREADY_MEMBER,NAME_TAKEN,INSUFFICIENT,FAILED}
|
||||
public enum Status{APPLIED,INVALID_SESSION,TOO_FAR,INVALID_NAME,ALREADY_MEMBER,NAME_TAKEN,INSUFFICIENT,FAILED,ERROR}
|
||||
public record Outcome(Status status,Optional<CharacterParty> party){}
|
||||
private record Context(EquipmentSessions.Active active,CharacterRecord record,PartyRegistrar registrar){}
|
||||
private final CharacterPartyDirectory parties;private final PartyRegistrarDirectory registrars;private final CharacterStorage characters;private final EquipmentSessions sessions;private final PartyEconomySettings prices;private final PartyOperationStore operations;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private volatile boolean closed;private volatile boolean recovered;private final java.util.concurrent.CompletableFuture<Void> recovery=new java.util.concurrent.CompletableFuture<>();
|
||||
private final CharacterPartyDirectory parties;private final PartyRegistrarDirectory registrars;private final CharacterStorage characters;private final EquipmentSessions sessions;private final PartyEconomySettings prices;private final PartyOperationStore operations;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private games.dmg.fantasystick.events.PartyEventBridge events;private volatile boolean closed;private volatile boolean recovered;private final java.util.concurrent.CompletableFuture<Void> recovery=new java.util.concurrent.CompletableFuture<>();
|
||||
public PartyManagement(CharacterPartyDirectory parties,PartyRegistrarDirectory registrars,CharacterStorage characters,EquipmentSessions sessions,PartyEconomySettings prices,PartyOperationStore operations,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.parties=Objects.requireNonNull(parties);this.registrars=Objects.requireNonNull(registrars);this.characters=Objects.requireNonNull(characters);this.sessions=Objects.requireNonNull(sessions);this.prices=Objects.requireNonNull(prices);this.operations=Objects.requireNonNull(operations);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);parties.readiness().thenCompose(ignored->operations.load()).thenCompose(plans->java.util.concurrent.CompletableFuture.allOf(plans.stream().map(plan->execute(plan,null,null).toCompletableFuture()).toArray(java.util.concurrent.CompletableFuture[]::new))).whenComplete((ignored,failure)->{if(failure==null){recovered=true;recovery.complete(null);}else{report(failure);recovery.completeExceptionally(failure);}});}
|
||||
public void register(Player player,UUID registrarId,String name,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var parsed=PartyNames.parse(name);if(parsed.isEmpty()){result.accept(new Outcome(Status.INVALID_NAME,Optional.empty()));return;}var captured=context.orElseThrow();if(parties.partyOf(captured.record().id()).isPresent()){result.accept(new Outcome(Status.ALREADY_MEMBER,Optional.empty()));return;}if(parties.partyByCanonicalName(parsed.orElseThrow().canonical()).isPresent()){result.accept(new Outcome(Status.NAME_TAKEN,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.CREATE,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.registrationPrice()),parsed.orElseThrow().display(),null,null,null);prepare(plan,captured,result);}
|
||||
public void transfer(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var party=parties.partyOf(captured.record().id()).orElse(null);if(party==null||!party.leader().equals(captured.record().id())||!party.members().contains(target)||target.equals(captured.record().id())){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.TRANSFER,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.leaderChangePrice()),null,party.id(),target,party.revision());prepare(plan,captured,result);}
|
||||
public void attachEvents(games.dmg.fantasystick.events.PartyEventBridge value){if(closed||events!=null)throw new IllegalStateException("Party events already attached or management closed");events=Objects.requireNonNull(value);}
|
||||
public void register(Player player,UUID registrarId,String name,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var before=parties.partyOf(captured.record().id()).orElse(null);var operation=UUID.randomUUID();var audit=audit(player,captured,"create",null,null,before,BigInteger.valueOf(prices.registrationPrice()),operation);var audited=wrap(audit,result);var parsed=PartyNames.parse(name);if(parsed.isEmpty()){audited.accept(new Outcome(Status.INVALID_NAME,Optional.empty()));return;}if(before!=null){audited.accept(new Outcome(Status.ALREADY_MEMBER,Optional.of(before)));return;}if(parties.partyByCanonicalName(parsed.orElseThrow().canonical()).isPresent()){audited.accept(new Outcome(Status.NAME_TAKEN,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(operation,PartyOperationStore.Kind.CREATE,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.registrationPrice()),parsed.orElseThrow().display(),null,null,null);prepare(plan,captured,audited);}
|
||||
public void transfer(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var party=parties.partyOf(captured.record().id()).orElse(null);var operation=UUID.randomUUID();var audit=audit(player,captured,"transfer",target,null,party,BigInteger.valueOf(prices.leaderChangePrice()),operation);var audited=wrap(audit,result);if(party==null||!party.leader().equals(captured.record().id())||!party.members().contains(target)||target.equals(captured.record().id())){audited.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(operation,PartyOperationStore.Kind.TRANSFER,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.leaderChangePrice()),null,party.id(),target,party.revision());prepare(plan,captured,audited);}
|
||||
private void prepare(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result){operations.prepare(plan).whenComplete((saved,failure)->{if(failure!=null){fail(failure,result);return;}execute(saved,context,result);});}
|
||||
private java.util.concurrent.CompletionStage<Void> execute(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result){var done=new java.util.concurrent.CompletableFuture<Void>();parties.readiness().thenCompose(ignored->plan.kind()==PartyOperationStore.Kind.CREATE?parties.paidCreate(plan,characters):parties.paidTransfer(plan,characters)).whenComplete((paid,failure)->{if(failure!=null){if(result!=null)fail(failure,result);else report(failure);done.completeExceptionally(failure);return;}var spent=paid.spend();var changed=paid.result();if(spent!=null&&(spent.status()==CharacterStorage.CoinSpendStatus.INSUFFICIENT||spent.status()==CharacterStorage.CoinSpendStatus.UNAVAILABLE)){finish(plan,context,result,spent,new Outcome(Status.INSUFFICIENT,Optional.empty()),done);return;}if(spent==null){finish(plan,context,result,null,new Outcome(map(changed.status()),changed.party()),done);return;}if(spent.status()!=CharacterStorage.CoinSpendStatus.APPLIED&&spent.status()!=CharacterStorage.CoinSpendStatus.DUPLICATE){finish(plan,context,result,spent,new Outcome(Status.FAILED,Optional.empty()),done);return;}if(changed.status()!=CharacterPartyDirectory.Status.APPLIED&&changed.status()!=CharacterPartyDirectory.Status.DUPLICATE){finish(plan,context,result,spent,new Outcome(map(changed.status()),changed.party()),done);return;}finish(plan,context,result,spent,new Outcome(Status.APPLIED,changed.party()),done);});return done.minimalCompletionStage();}
|
||||
private void finish(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result,CharacterStorage.CoinSpend spent,Outcome outcome,java.util.concurrent.CompletableFuture<Void> done){operations.complete(plan.id()).whenComplete((ignored,failure)->{if(failure!=null){if(result!=null)fail(failure,result);else report(failure);done.completeExceptionally(failure);return;}if(result!=null)publish(()->{if(context!=null&&spent!=null&&spent.record().isPresent())sessions.acknowledgeProgress(context.active(),context.record().revision(),spent.record().orElseThrow());result.accept(outcome);});done.complete(null);});}
|
||||
private static Status map(CharacterPartyDirectory.Status status){return switch(status){case NAME_TAKEN->Status.NAME_TAKEN;case ALREADY_MEMBER->Status.ALREADY_MEMBER;default->Status.FAILED;};}
|
||||
public void invite(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var active=sessions.activeCharacter(target).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(record==null||parties.partyOf(target).isPresent()){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null||!party.leader().equals(context.orElseThrow().record().id())){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}parties.invite(context.orElseThrow().record().id(),party.id(),target).whenComplete((value,failure)->complete(value,failure,result));}
|
||||
public void accept(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutate(player,registrarId,c->parties.accept(c.record().id(),invitation),result);}public void decline(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutate(player,registrarId,c->parties.decline(c.record().id(),invitation),result);}public void cancel(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.cancel(c.record().id(),p.id(),invitation),result);}public void leave(Player player,UUID registrarId,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.leave(c.record().id(),p.id()),result);}public void kick(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.kick(c.record().id(),p.id(),target),result);}public void disband(Player player,UUID registrarId,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.disband(c.record().id(),p.id()),result);}
|
||||
public void invite(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var party=parties.partyOf(captured.record().id()).orElse(null);var audit=audit(player,captured,"invite",target,null,party,BigInteger.ZERO,null);var audited=wrap(audit,result);var active=sessions.activeCharacter(target).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(record==null||parties.partyOf(target).isPresent()||party==null||!party.leader().equals(captured.record().id())){audited.accept(new Outcome(Status.FAILED,Optional.empty()));return;}parties.invite(captured.record().id(),party.id(),target).whenComplete((value,failure)->complete(value,failure,audited));}
|
||||
public void accept(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){var c=context(player,registrarId);var before=partyForInvitation(invitation);var audit=c.map(value->audit(player,value,"accept",value.record().id(),invitation,before,BigInteger.ZERO,null)).orElse(null);mutate(player,registrarId,value->parties.accept(value.record().id(),invitation),wrap(audit,result));}
|
||||
public void decline(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){var c=context(player,registrarId);var before=partyForInvitation(invitation);var audit=c.map(value->audit(player,value,"decline",value.record().id(),invitation,before,BigInteger.ZERO,null)).orElse(null);mutate(player,registrarId,value->parties.decline(value.record().id(),invitation),wrap(audit,result));}
|
||||
public void cancel(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);var invited=before==null?null:before.invitations().stream().filter(value->value.id().equals(invitation)).map(CharacterParty.Invitation::character).findFirst().orElse(null);var audit=c.map(value->audit(player,value,"cancel",invited,invitation,before,BigInteger.ZERO,null)).orElse(null);mutateParty(player,registrarId,(value,p)->parties.cancel(value.record().id(),p.id(),invitation),wrap(audit,result));}
|
||||
public void leave(Player player,UUID registrarId,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);boolean leaderLeave=before!=null&&c.isPresent()&&before.leader().equals(c.orElseThrow().record().id());String kind=leaderLeave?"leader_leave":"leave";var audit=c.map(value->audit(player,value,kind,value.record().id(),null,before,BigInteger.ZERO,null,leaderLeave?"leader_leave":"requested")).orElse(null);mutateParty(player,registrarId,(value,p)->parties.leave(value.record().id(),p.id()),wrap(audit,result));}
|
||||
public void kick(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);var audit=c.map(value->audit(player,value,"kick",target,null,before,BigInteger.ZERO,null)).orElse(null);mutateParty(player,registrarId,(value,p)->parties.kick(value.record().id(),p.id(),target),wrap(audit,result));}
|
||||
public void disband(Player player,UUID registrarId,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);var audit=c.map(value->audit(player,value,"disband",value.record().id(),null,before,BigInteger.ZERO,null)).orElse(null);mutateParty(player,registrarId,(value,p)->parties.disband(value.record().id(),p.id()),wrap(audit,result));}
|
||||
public Optional<CharacterParty> party(Player player,UUID registrar){return context(player,registrar).flatMap(value->parties.partyOf(value.record().id()));}public List<CharacterPartyDirectory.Offer> invitations(Player player,UUID registrar){return context(player,registrar).map(value->parties.invitationsFor(value.record().id())).orElse(List.of());}public List<CharacterPartyDirectory.Offer> outgoingInvitations(Player player,UUID registrar){var context=context(player,registrar);if(context.isEmpty())return List.of();var party=parties.partyOf(context.orElseThrow().record().id()).filter(value->value.leader().equals(context.orElseThrow().record().id())).orElse(null);return party==null?List.of():party.invitations().stream().map(value->new CharacterPartyDirectory.Offer(value.id(),party.id(),value.character())).toList();}
|
||||
private void mutate(Player player,UUID id,java.util.function.Function<Context,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}try{action.apply(context.orElseThrow()).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
|
||||
private void mutateParty(Player player,UUID id,java.util.function.BiFunction<Context,CharacterParty,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}try{action.apply(context.orElseThrow(),party).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
|
||||
@@ -29,7 +35,12 @@ public final class PartyManagement implements AutoCloseable {
|
||||
public int registrationPrice(){return prices.registrationPrice();}public int leaderChangePrice(){return prices.leaderChangePrice();}
|
||||
public Optional<CharacterRecord> active(Player player,UUID registrar){return context(player,registrar).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID registrar){return context(player,registrar).map(value->value.active().session());}public boolean eligibleInvitationTarget(UUID character){return parties.partyOf(character).isEmpty()&&sessions.activeCharacter(character).filter(sessions::current).flatMap(sessions::currentRecord).filter(value->!value.dead()).isPresent();}public Optional<CharacterRecord> activeCharacter(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);return active==null?Optional.empty():sessions.currentRecord(active).filter(value->!value.dead());}
|
||||
private Optional<Context> context(Player player,UUID registrarId){if(!parties.ready()||!recovered)return Optional.empty();var registrar=registrars.find(registrarId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(registrar==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(registrar.world()))return Optional.empty();double dx=location.getX()-registrar.x(),dy=location.getY()-registrar.y(),dz=location.getZ()-registrar.z();if(dx*dx+dy*dy+dz*dz>36)return Optional.empty();return Optional.of(new Context(active,record,registrar));}
|
||||
private void fail(Throwable failure,Consumer<Outcome> result){report(failure);publish(()->result.accept(new Outcome(Status.FAILED,Optional.empty())));}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}private void publish(Runnable action){if(closed)return;try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
|
||||
private CharacterParty partyForInvitation(UUID invitation){return parties.all().stream().filter(party->party.invitations().stream().anyMatch(value->value.id().equals(invitation))).findFirst().orElse(null);}
|
||||
private games.dmg.fantasystick.events.PartyEventBridge.Action audit(Player player,Context context,String kind,UUID target,UUID invitation,CharacterParty before,BigInteger charge,UUID operation){return audit(player,context,kind,target,invitation,before,charge,operation,"requested");}
|
||||
private games.dmg.fantasystick.events.PartyEventBridge.Action audit(Player player,Context context,String kind,UUID target,UUID invitation,CharacterParty before,BigInteger charge,UUID operation,String reason){return events==null?null:events.begin(player,context.active(),context.record().name(),context.registrar(),kind,target,targetContext(player,context,target),invitation,before,charge,operation,reason);}
|
||||
private games.dmg.fantasystick.events.EventInput.Context targetContext(Player actor,Context context,UUID target){if(target==null||events==null)return null;if(target.equals(context.record().id()))return events.context(actor,context.active(),context.record().name());var active=sessions.activeCharacter(target).orElse(null);if(active==null)return null;var player=sessions.currentPlayer(active).orElse(null);var record=sessions.currentRecord(active).orElse(null);return player==null||record==null?null:events.context(player,active,record.name());}
|
||||
private Consumer<Outcome> wrap(games.dmg.fantasystick.events.PartyEventBridge.Action audit,Consumer<Outcome> target){return outcome->{if(audit!=null){if(outcome.status()==Status.ERROR)events.failed(audit,"mutation_failed_or_uncertain");else events.resolved(audit,outcome);}target.accept(outcome);};}
|
||||
private void fail(Throwable failure,Consumer<Outcome> result){report(failure);publish(()->result.accept(new Outcome(Status.ERROR,Optional.empty())));}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}private void publish(Runnable action){if(closed)return;try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
|
||||
public java.util.concurrent.CompletionStage<Void> ready(){return recovery.minimalCompletionStage();}
|
||||
public java.util.concurrent.CompletionStage<Void> drained(){return operations.drained();}
|
||||
@Override public void close(){closed=true;operations.close();}
|
||||
|
||||
@@ -27,6 +27,7 @@ public final class PartyRegistrarController implements Listener, AutoCloseable {
|
||||
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}}
|
||||
public boolean tagged(Entity entity){return "party_registrar".equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
|
||||
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,PartyRegistrar>();for(var value:directory.all())if(value.world().equals(chunk.getWorld().getUID())&&chunk(value.x())==chunk.getX()&&chunk(value.z())==chunk.getZ())desired.put(value.id(),value);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(!tagged(entity))continue;UUID id=id(entity);var target=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||target==null||!retained.add(id)){entity.remove();continue;}configure(villager,target);var current=villager.getLocation();var expected=location(target);if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawn(target);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawn(value);}
|
||||
public java.util.Optional<UUID> loadedEntity(UUID id){for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity)&&id.equals(id(entity)))return java.util.Optional.ofNullable(entity.getUniqueId());}return java.util.Optional.empty();}
|
||||
public void removeLoaded(UUID id){for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity)&&id.equals(id(entity)))entity.remove();}}
|
||||
private void spawn(PartyRegistrar value){var world=plugin.getServer().getWorld(value.world());if(world==null)return;int x=chunk(value.x()),z=chunk(value.z());if(!world.isChunkLoaded(x,z))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
|
||||
private void configure(Villager villager,PartyRegistrar value){villager.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,"party_registrar");villager.getPersistentDataContainer().set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.name().isBlank()?"Party Registrar":value.name()));villager.setCustomNameVisible(true);}
|
||||
|
||||
@@ -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"); }
|
||||
}
|
||||
|
||||
@@ -72,10 +72,11 @@ public final class ChainLightningSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public ChainLightning.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -27,6 +27,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
|
||||
private final java.util.random.RandomGenerator coinRandom;
|
||||
private final CharacterRecoveryStore recoveries;
|
||||
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-character-persistence"));
|
||||
|
||||
@@ -47,6 +48,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void attachUnavailableCharacter(java.util.function.Predicate<UUID> predicate){repository.attachUnavailable(predicate);}
|
||||
public void attachCoinEvents(games.dmg.fantasystick.events.CoinEventBridge value){if(worker.isShutdown()||coinEvents!=null)throw new IllegalStateException("Coin events already attached or storage closed");coinEvents=Objects.requireNonNull(value);}
|
||||
|
||||
public CompletableFuture<Void> create(CharacterRecord character) {
|
||||
return submit(() -> {
|
||||
@@ -60,7 +62,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
* starting points and retains the returned handle through success, cancellation and disable.
|
||||
* No inventory access occurs here; abandoning an observer is not abandoning the attempt.
|
||||
*/
|
||||
public CraftPreparation prepareCraft(List<CharacterRecord> records) {
|
||||
public CraftPreparation prepareCraft(List<CharacterRecord> records) {return prepareCraft(records,"craft");}
|
||||
public CraftPreparation prepareCraft(List<CharacterRecord> records,String source) {
|
||||
var snapshot = List.copyOf(records);
|
||||
var ids = new HashSet<UUID>();
|
||||
if (snapshot.isEmpty()) throw new IllegalArgumentException("Craft batch must not be empty");
|
||||
@@ -75,17 +78,19 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
throw new IllegalArgumentException("Craft batch requires distinct new characters");
|
||||
}
|
||||
}
|
||||
return new CraftPreparation(snapshot);
|
||||
return new CraftPreparation(snapshot,Objects.requireNonNull(source),coinRules.get());
|
||||
}
|
||||
|
||||
public final class CraftPreparation {
|
||||
private final List<CharacterRecord> requested;
|
||||
private final CompletableFuture<CraftBatch> completion;
|
||||
private final String source;
|
||||
private final games.dmg.fantasystick.progression.CoinRules configuredRules;
|
||||
private volatile boolean abandoned;
|
||||
private CompletableFuture<CraftCleanup> cleanup;
|
||||
|
||||
private CraftPreparation(List<CharacterRecord> records) {
|
||||
requested = records;
|
||||
private CraftPreparation(List<CharacterRecord> records,String source,games.dmg.fantasystick.progression.CoinRules configuredRules) {
|
||||
requested = records;this.source=source;this.configuredRules=Objects.requireNonNull(configuredRules);
|
||||
completion = writeCraftBatch(records, this);
|
||||
}
|
||||
|
||||
@@ -173,6 +178,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (resolved || owner.abandoned) return Optional.empty();
|
||||
resolved = true;
|
||||
if (worker.isShutdown()) return Optional.empty();
|
||||
var bridge=coinEvents;if(bridge!=null)for(var record:records)bridge.initialized(record,owner.source,owner.configuredRules);
|
||||
return Optional.of(records);
|
||||
}
|
||||
|
||||
@@ -311,11 +317,11 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
reconcileRecovery(found.orElseThrow());
|
||||
if (found.orElseThrow().coinsInitialized()) return found;
|
||||
var old = found.orElseThrow();
|
||||
var migrated = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
var configuredRules=coinRules.get();var migrated = new CharacterRecord(old.id(), old.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(), old.dead(), old.deathId(), old.diedAt(),
|
||||
coinRules.get().starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(migrated, old.revision());
|
||||
configuredRules.starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(migrated, old.revision());var bridge=coinEvents;if(bridge!=null)bridge.initialized(migrated,"legacy_migration",configuredRules);
|
||||
return Optional.of(migrated);
|
||||
}
|
||||
|
||||
@@ -364,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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -801,7 +816,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
}
|
||||
|
||||
/** Finalize one claimed downed outcome with its stable death identity and original resolution time. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> recordFinalDeath(UUID characterId,BigInteger expectedRevision,UUID deathId,Instant diedAt,boolean loseCoins){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(deathId);Objects.requireNonNull(diedAt);return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return Optional.empty();var old=found.orElseThrow();if(old.dead())return old.deathId().equals(deathId)&&old.diedAt().equals(diedAt)?Optional.of(old):Optional.empty();if(!old.revision().equals(expectedRevision))return Optional.empty();var dead=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),java.math.BigDecimal.ZERO,old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),true,deathId,diedAt,loseCoins?BigInteger.ZERO:old.coins(),old.coinsInitialized(),old.rewardReceipts(),loseCoins?old.coins():BigInteger.ZERO,old.gear(),old.sheetGeneration());repository.replace(dead,old.revision());return Optional.of(dead);});}
|
||||
public CompletableFuture<Optional<CharacterRecord>> recordFinalDeath(UUID characterId,BigInteger expectedRevision,UUID deathId,Instant diedAt,boolean loseCoins){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(deathId);Objects.requireNonNull(diedAt);return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return Optional.empty();var old=found.orElseThrow();if(old.dead())return old.deathId().equals(deathId)&&old.diedAt().equals(diedAt)?Optional.of(old):Optional.empty();if(!old.revision().equals(expectedRevision))return Optional.empty();var dead=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),java.math.BigDecimal.ZERO,old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),true,deathId,diedAt,loseCoins?BigInteger.ZERO:old.coins(),old.coinsInitialized(),old.rewardReceipts(),loseCoins?old.coins():BigInteger.ZERO,old.gear(),old.sheetGeneration());repository.replace(dead,old.revision());var bridge=coinEvents;if(bridge!=null&&loseCoins&&old.coins().signum()>0)bridge.lost(old,dead,deathId,diedAt);return Optional.of(dead);});}
|
||||
|
||||
private CompletableFuture<Optional<CharacterRecord>> death(UUID characterId, BigInteger expectedRevision, boolean loseCoins) {
|
||||
Objects.requireNonNull(characterId);
|
||||
@@ -876,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() {
|
||||
|
||||
@@ -71,10 +71,11 @@ public final class FrostNovaSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public FrostNova.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -70,10 +70,11 @@ public final class GuardianBondSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public GuardianBond.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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 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) {
|
||||
|
||||
@@ -69,10 +69,11 @@ public final class MendingLightSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public MendingLight.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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,10 +72,11 @@ public final class MeteorSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public Meteor.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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) {
|
||||
|
||||
@@ -42,29 +42,81 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
private AccountPreference { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
|
||||
}
|
||||
|
||||
public record Update(UUID account, Preference preference, boolean enabled, long sequence) {
|
||||
public record EventIntent(UUID actionId, Map<String, String> captured, long sequence) {
|
||||
public EventIntent { Objects.requireNonNull(actionId); captured = Map.copyOf(captured); }
|
||||
public EventIntent(UUID actionId, Map<String, String> captured) { this(actionId, captured, 0); }
|
||||
}
|
||||
|
||||
public record Update(UUID account, Preference preference, boolean enabled, long sequence, EventIntent intent) {
|
||||
public Update { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
|
||||
public Update(UUID account, Preference preference, boolean enabled, long sequence) {
|
||||
this(account, preference, enabled, sequence, null);
|
||||
}
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-player-feedback-preferences"));
|
||||
private final Map<AccountPreference, Boolean> effective = new java.util.HashMap<>();
|
||||
private final Map<AccountPreference, Boolean> saved = new java.util.HashMap<>();
|
||||
private final Map<AccountPreference, Long> published = new java.util.HashMap<>();
|
||||
private long persistedSequence;
|
||||
private final Map<UUID, EventIntent> pendingEvents = new LinkedHashMap<>();
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public PlayerFeedbackPreferences(Path directory) throws IOException {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
effective.putAll(loadEnabled());
|
||||
var loaded = loadEnabled();
|
||||
effective.putAll(loaded);
|
||||
saved.putAll(loaded);
|
||||
var root = readRoot();
|
||||
var persisted = root.get("preference-sequence");
|
||||
if (persisted != null) {
|
||||
try { persistedSequence = Long.parseLong(String.valueOf(persisted)); }
|
||||
catch (NumberFormatException invalid) { throw new IOException("Invalid preference sequence", invalid); }
|
||||
if (persistedSequence < 0) throw new IOException("Invalid preference sequence");
|
||||
}
|
||||
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
|
||||
for (var entry : pending.entrySet()) {
|
||||
UUID id;
|
||||
try { id = UUID.fromString(entry.getKey()); }
|
||||
catch (IllegalArgumentException invalid) { throw new IOException("Invalid pending preference event identity", invalid); }
|
||||
var row = stringMap(entry.getValue(), "pending preference event");
|
||||
var captured = new LinkedHashMap<String, String>();
|
||||
for (var field : row.entrySet()) {
|
||||
if (!(field.getValue() instanceof String text)) throw new IOException("Invalid pending preference field");
|
||||
captured.put(field.getKey(), text);
|
||||
}
|
||||
long sequence;
|
||||
try { sequence = Long.parseLong(captured.remove("preference_sequence")); }
|
||||
catch (RuntimeException invalid) { throw new IOException("Invalid pending preference sequence", invalid); }
|
||||
if (sequence <= 0) throw new IOException("Invalid pending preference sequence");
|
||||
pendingEvents.put(id, new EventIntent(id, captured, sequence));
|
||||
persistedSequence = Math.max(persistedSequence, sequence);
|
||||
}
|
||||
}
|
||||
|
||||
/** Existing routine combat/effect feedback preference. */
|
||||
public synchronized boolean enabled(UUID account) { return value(account, Preference.COMBAT_FEEDBACK); }
|
||||
public synchronized boolean savedEnabled(UUID account) {
|
||||
Objects.requireNonNull(account);
|
||||
return saved.getOrDefault(new AccountPreference(account, Preference.COMBAT_FEEDBACK),
|
||||
Preference.COMBAT_FEEDBACK.defaultValue);
|
||||
}
|
||||
public synchronized boolean playerAurasEnabled(UUID account) { return value(account, Preference.PLAYER_AURAS); }
|
||||
public synchronized boolean allParticlesEnabled(UUID account) { return value(account, Preference.ALL_PARTICLES); }
|
||||
public synchronized boolean savedPlayerAurasEnabled(UUID account) {
|
||||
Objects.requireNonNull(account);
|
||||
return saved.getOrDefault(new AccountPreference(account, Preference.PLAYER_AURAS),
|
||||
Preference.PLAYER_AURAS.defaultValue);
|
||||
}
|
||||
public synchronized boolean savedAllParticlesEnabled(UUID account) {
|
||||
Objects.requireNonNull(account);
|
||||
return saved.getOrDefault(new AccountPreference(account, Preference.ALL_PARTICLES),
|
||||
Preference.ALL_PARTICLES.defaultValue);
|
||||
}
|
||||
public synchronized boolean auraParticlesEnabled(UUID account) {
|
||||
return value(account, Preference.ALL_PARTICLES) && value(account, Preference.PLAYER_AURAS);
|
||||
}
|
||||
@@ -78,16 +130,94 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
return set(account, Preference.COMBAT_FEEDBACK, enabled);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(UUID account, boolean enabled, EventIntent intent) {
|
||||
return set(account, Preference.COMBAT_FEEDBACK, enabled, intent);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled) {
|
||||
return set(account, Preference.PLAYER_AURAS, enabled);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled, EventIntent intent) {
|
||||
return set(account, Preference.PLAYER_AURAS, enabled, intent);
|
||||
}
|
||||
|
||||
/** Shared persisted precedence for the separately delivered all-particles command. */
|
||||
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled) {
|
||||
return set(account, Preference.ALL_PARTICLES, enabled);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled, EventIntent intent) {
|
||||
return set(account, Preference.ALL_PARTICLES, enabled, intent);
|
||||
}
|
||||
|
||||
public synchronized java.util.List<EventIntent> pendingEvents() { return java.util.List.copyOf(pendingEvents.values()); }
|
||||
|
||||
/** Persist native publication proof independently of the event writer before a recovered success is claimed. */
|
||||
public CompletionStage<Void> markPublished(UUID action) {
|
||||
Objects.requireNonNull(action);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Player preferences closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
try (var channel = FileChannel.open(directory.resolve(".player-preferences.lock"),
|
||||
StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Player preferences busy");
|
||||
var root = readRoot();
|
||||
var all = stringMap(root.get("pending-preference-events"), "pending preference events");
|
||||
var found = stringMap(all.get(action.toString()), "pending preference event");
|
||||
if (found.isEmpty()) return; // Already accepted by the event writer.
|
||||
found.put("published", "true");
|
||||
all.put(action.toString(), found);
|
||||
root.put("pending-preference-events", all);
|
||||
writeRoot(root);
|
||||
synchronized (this) {
|
||||
var previous = pendingEvents.get(action);
|
||||
if (previous != null) {
|
||||
var snapshot = new LinkedHashMap<>(previous.captured());
|
||||
snapshot.put("published", "true");
|
||||
pendingEvents.put(action, new EventIntent(action, snapshot, previous.sequence()));
|
||||
}
|
||||
}
|
||||
} catch (IOException | OverlappingFileLockException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure);
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(rejected);
|
||||
}
|
||||
}
|
||||
|
||||
public CompletionStage<Void> acknowledgeEvent(UUID action) {
|
||||
Objects.requireNonNull(action);
|
||||
try {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
try (var channel = FileChannel.open(directory.resolve(".player-preferences.lock"),
|
||||
StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Player preferences busy");
|
||||
Map<String,Object> root = readRoot();
|
||||
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
|
||||
if (pending.remove(action.toString()) == null) return;
|
||||
root.put("pending-preference-events", pending);
|
||||
writeRoot(root);
|
||||
synchronized (this) { pendingEvents.remove(action); }
|
||||
} catch (IOException | OverlappingFileLockException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure);
|
||||
}
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException closedWorker) { return CompletableFuture.failedStage(closedWorker); }
|
||||
}
|
||||
|
||||
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled) {
|
||||
return set(account, preference, enabled, null);
|
||||
}
|
||||
|
||||
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled, EventIntent intent) {
|
||||
Objects.requireNonNull(account); Objects.requireNonNull(preference);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Player preference store is closed"));
|
||||
@@ -96,8 +226,9 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
save(account, preference, enabled);
|
||||
return new Update(account, preference, enabled, ++persistedSequence);
|
||||
long sequence = ++persistedSequence;
|
||||
var accepted = save(account, preference, enabled, intent, sequence);
|
||||
return new Update(account, preference, enabled, sequence, accepted);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(new IOException(
|
||||
"Player preference save refused or failed; effective setting unchanged", failure));
|
||||
@@ -142,7 +273,8 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
return result;
|
||||
}
|
||||
|
||||
private void save(UUID account, Preference preference, boolean enabled) throws IOException {
|
||||
private EventIntent save(UUID account, Preference preference, boolean enabled, EventIntent intent,
|
||||
long sequence) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe data directory");
|
||||
Path lockFile = directory.resolve(".player-preferences.lock");
|
||||
@@ -155,9 +287,19 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
Map<String, Object> root = readRoot();
|
||||
Map<String, Object> players = stringMap(root.get(PLAYERS), "player preferences");
|
||||
Map<String, Object> values = stringMap(players.get(account.toString()), "player preference entry");
|
||||
boolean before = values.get(preference.key) instanceof Boolean stored ? stored : preference.defaultValue;
|
||||
values.put(preference.key, enabled);
|
||||
players.put(account.toString(), values);
|
||||
root.put(PLAYERS, players);
|
||||
root.put("preference-sequence", Long.toString(sequence));
|
||||
EventIntent accepted = intent != null && before != enabled ? new EventIntent(intent.actionId(), intent.captured(), sequence) : null;
|
||||
if (accepted != null) {
|
||||
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
|
||||
var captured = new LinkedHashMap<String,Object>(accepted.captured());
|
||||
captured.put("preference_sequence", Long.toString(sequence));
|
||||
pending.put(accepted.actionId().toString(), captured);
|
||||
root.put("pending-preference-events", pending);
|
||||
}
|
||||
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Player preferences too large");
|
||||
Path temporary = Files.createTempFile(directory, ".player-preferences-", ".tmp");
|
||||
@@ -171,11 +313,32 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
DurableFiles.forceDirectory(directory);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
synchronized (this) {
|
||||
saved.put(new AccountPreference(account, preference), enabled);
|
||||
if (accepted != null) pendingEvents.put(accepted.actionId(), accepted);
|
||||
}
|
||||
return accepted;
|
||||
} catch (OverlappingFileLockException busy) {
|
||||
throw new IOException("Player preferences busy", busy);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeRoot(Map<String,Object> root) throws IOException {
|
||||
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Player preferences too large");
|
||||
Path temporary = Files.createTempFile(directory, ".player-preferences-", ".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);
|
||||
}
|
||||
Files.move(temporary, directory.resolve(FILE), StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
DurableFiles.forceDirectory(directory);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
}
|
||||
|
||||
private Map<String, Object> readRoot() throws IOException {
|
||||
Path file = directory.resolve(FILE);
|
||||
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return new LinkedHashMap<>();
|
||||
|
||||
@@ -67,10 +67,11 @@ public final class PurifySettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public Purify.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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) {
|
||||
|
||||
@@ -71,10 +71,11 @@ public final class RallyingCrySettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public RallyingCry.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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) {
|
||||
|
||||
@@ -73,10 +73,11 @@ public final class SanctuarySettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public Sanctuary.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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) {
|
||||
|
||||
@@ -75,10 +75,11 @@ public final class WarBannerSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public WarBanner.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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,16 +38,17 @@ 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();
|
||||
attempt.preparation = storage.prepareCraft(List.of(attempt.record));
|
||||
attempt.preparation = storage.prepareCraft(List.of(attempt.record),"admin_issue");
|
||||
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
|
||||
} catch (RuntimeException failure) {
|
||||
pending.remove(attempt.playerId, attempt);
|
||||
@@ -74,9 +81,15 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
|| !claimed.orElseThrow().getFirst().id().equals(attempt.record.id())) {
|
||||
abandon(attempt); respond(attempt, Outcome.RETAINED); return;
|
||||
}
|
||||
var savedRecord = claimed.orElseThrow().getFirst();
|
||||
try {
|
||||
var sheet = new CharacterSheets().create(attempt.record);
|
||||
var sheets = new CharacterSheets();
|
||||
var sheet = sheets.create(savedRecord);
|
||||
attempt.player.getInventory().setItem(attempt.slot, sheet);
|
||||
if (!sheets.identify(attempt.player.getInventory().getItem(attempt.slot))
|
||||
.filter(savedRecord.id()::equals).isPresent()) {
|
||||
respond(attempt, Outcome.RETAINED); return;
|
||||
}
|
||||
attempt.delivered = true;
|
||||
} catch (RuntimeException uncertain) {
|
||||
report(attempt, "delivery", uncertain);
|
||||
@@ -85,6 +98,8 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
}
|
||||
try { attempt.player.updateInventory(); }
|
||||
catch (RuntimeException syncFailure) { report(attempt, "sync", syncFailure); }
|
||||
var events = identityEvents;
|
||||
if (events != null) events.issued(attempt.issue, attempt.player, savedRecord, attempt.slot);
|
||||
respond(attempt, Outcome.DELIVERED);
|
||||
}
|
||||
|
||||
@@ -114,8 +129,12 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
|
||||
private static final class Attempt {
|
||||
final Player player; final UUID playerId; final int slot; final Consumer<Outcome> response;
|
||||
final games.dmg.fantasystick.events.CharacterIdentityEventBridge.Issue issue;
|
||||
final java.util.concurrent.atomic.AtomicBoolean abandoned = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
CharacterRecord record; CharacterStorage.CraftPreparation preparation; volatile boolean delivered;
|
||||
Attempt(Player player, int slot, Consumer<Outcome> response) { this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; }
|
||||
Attempt(Player player, int slot, Consumer<Outcome> response,
|
||||
games.dmg.fantasystick.events.CharacterIdentityEventBridge.Issue issue) {
|
||||
this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; this.issue=issue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,12 @@ public final class AdminRevival implements Listener, AutoCloseable {
|
||||
private final Consumer<Runnable> nextTick; private final Scheduler scheduler; private final PopupFactory popups;
|
||||
private final Consumer<Throwable> failures; private final TailBooks tails=new TailBooks();private final CharacterSheets sheets=new CharacterSheets();
|
||||
private final Map<Inventory,View> views=new IdentityHashMap<>();private boolean closed;
|
||||
private games.dmg.fantasystick.events.AdminRevivalEventBridge auditEvents;
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events) {
|
||||
if (closed || auditEvents != null) throw new IllegalStateException("Admin revival events already attached");
|
||||
auditEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
private final class View implements AutoCloseable {
|
||||
final Player admin,target;final Inventory inventory;final Map<Integer,Choice> choices;final Instant deadline;final AutoCloseable expiry;boolean selecting,finished;
|
||||
@@ -58,9 +64,9 @@ public final class AdminRevival implements Listener, AutoCloseable {
|
||||
@EventHandler(priority=EventPriority.MONITOR) public void quit(org.bukkit.event.player.PlayerQuitEvent event){for(var view:List.copyOf(views.values()))if(view.admin==event.getPlayer()||view.target==event.getPlayer())view.close();}
|
||||
|
||||
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival could not recheck that Tail; no item changed.");view.close();return;}if(!view.current()||found.isEmpty()||!eligible(found.orElseThrow(),choice.candidate.identity)||!unchanged(view.target,choice.candidate)){view.admin.sendMessage("That Tail selection is stale; no item changed.");view.close();return;}reserve(view,choice.candidate,found.orElseThrow());}));}
|
||||
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation);}));}
|
||||
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);var audit=auditEvents==null?null:auditEvents.selection(view.admin,view.target);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation,audit);}));}
|
||||
private static int emptySlot(Player target,int source){for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40||slot==source)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())return slot;}return-1;}
|
||||
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt(),dead.sheetGeneration());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
|
||||
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation,games.dmg.fantasystick.events.AdminRevivalEventBridge.Selection audit){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt(),dead.sheetGeneration());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){if(auditEvents!=null)auditEvents.revived(audit,reservation,dead,restored.orElseThrow(),view.target);reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
|
||||
private boolean installFinal(Player target,CharacterRecord record){int found=-1;for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;if(sheets.identify(target.getInventory().getItem(slot)).filter(record.id()::equals).isEmpty())continue;if(found>=0)return false;found=slot;}if(found<0)return false;target.getInventory().setItem(found,sheets.create(record));target.updateInventory();return true;}
|
||||
private void schedule(Runnable action){try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
|
||||
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
|
||||
@@ -33,7 +33,7 @@ public final class DownedDirectory implements AutoCloseable{
|
||||
|
||||
private Map<UUID,DownedEpisode> load()throws IOException{if(Files.notExists(file))return Map.of();try{if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS)||Files.isSymbolicLink(file)||Files.size(file)>1_048_576)throw new IllegalArgumentException();Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||!"1".equals(String.valueOf(root.get("schema-version"))))throw new IllegalArgumentException();var result=new LinkedHashMap<UUID,DownedEpisode>();for(Object raw:list(root.get("episodes"))){var row=map(raw);var value=decode(row);if(result.put(value.character(),value)!=null)throw new IllegalArgumentException();}return Map.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid downed state",invalid);}}
|
||||
private void save(Map<UUID,DownedEpisode> state)throws IOException{Files.createDirectories(file.getParent());var root=new LinkedHashMap<String,Object>();root.put("schema-version","1");root.put("episodes",state.values().stream().sorted(Comparator.comparing(DownedEpisode::character)).map(this::encode).toList());Path temporary=Files.createTempFile(file.getParent(),".downed-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var out=FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())out.write(buffer);out.force(true);}Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);try(var directory=FileChannel.open(file.getParent(),StandardOpenOption.READ)){directory.force(true);}snapshot=Map.copyOf(state);}finally{Files.deleteIfExists(temporary);}}
|
||||
private Map<String,Object> encode(DownedEpisode value){var row=new LinkedHashMap<String,Object>();row.put("id",value.id().toString());row.put("character",value.character().toString());row.put("player",value.player().toString());row.put("session",value.session().toString());row.put("world",value.world().toString());row.put("x",BigDecimal.valueOf(value.x()).toPlainString());row.put("y",BigDecimal.valueOf(value.y()).toPlainString());row.put("z",BigDecimal.valueOf(value.z()).toPlainString());row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));row.put("entry-world",value.entryWorld().toString());row.put("entry-x",BigDecimal.valueOf(value.entryX()).toPlainString());row.put("entry-y",BigDecimal.valueOf(value.entryY()).toPlainString());row.put("entry-z",BigDecimal.valueOf(value.entryZ()).toPlainString());row.put("entry-yaw",Float.toString(value.entryYaw()));row.put("entry-pitch",Float.toString(value.entryPitch()));row.put("downed-at",value.downedAt().toString());row.put("expires-at",value.expiresAt().toString());row.put("attacker",value.attribution().attacker()==null?null:value.attribution().attacker().toString());row.put("cause",value.attribution().cause());row.put("player-defeat",value.attribution().playerDefeat());row.put("contributors",value.attribution().contributors().stream().map(UUID::toString).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("victim-level",value.attribution().victimLevel());row.put("victim-coins",value.attribution().victimCoins().toString());row.put("character-name",value.characterName());row.put("player-name",value.playerName());row.put("character-revision",value.characterRevision()==null?null:value.characterRevision().toString());row.put("phase",value.phase().name());row.put("outcome",value.outcome()==null?null:value.outcome().name());row.put("outcome-operation",value.outcomeOperation()==null?null:value.outcomeOperation().toString());row.put("resolved-at",value.resolvedAt()==null?null:value.resolvedAt().toString());row.put("protection-until",value.protectionUntil()==null?null:value.protectionUntil().toString());var decision=value.decision();row.put("decision-source",decision==null?null:decision.source());row.put("decision-actor-player",decision==null||decision.actorPlayer()==null?null:decision.actorPlayer().toString());row.put("decision-actor-player-name",decision==null?null:decision.actorPlayerName());row.put("decision-actor-character",decision==null||decision.actorCharacter()==null?null:decision.actorCharacter().toString());row.put("decision-actor-character-name",decision==null?null:decision.actorCharacterName());row.put("decision-actor-session",decision==null||decision.actorSession()==null?null:decision.actorSession().toString());row.put("decision-action",decision==null?null:decision.actionId().toString());row.put("decision-cast-at",decision==null||decision.castAt()==null?null:decision.castAt().toString());row.put("decision-cooldown-until",decision==null||decision.cooldownUntil()==null?null:decision.cooldownUntil().toString());row.put("decision-final-range",decision==null||decision.finalRange()==null?null:Double.toString(decision.finalRange()));row.put("decision-line-of-sight",decision==null?null:decision.lineOfSight());var phoenix=decision==null?null:decision.phoenix();row.put("decision-phoenix-quantity-before",phoenix==null?null:phoenix.quantityBefore().toString());row.put("decision-phoenix-quantity-after",phoenix==null?null:phoenix.quantityAfter().toString());row.put("decision-phoenix-health-before",phoenix==null?null:phoenix.healthBefore().toPlainString());row.put("decision-phoenix-health-after",phoenix==null?null:phoenix.healthAfter().toPlainString());row.put("decision-phoenix-maximum",phoenix==null?null:phoenix.maximum().toPlainString());row.put("decision-phoenix-entry-point",phoenix==null?null:phoenix.entryPoint());row.put("final-rewards",value.finalRewards().stream().map(reward->{var saved=new LinkedHashMap<String,Object>();saved.put("character",reward.character().toString());saved.put("experience",reward.experience().toString());saved.put("coins",reward.coins().toString());return saved;}).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("published",value.published());return row;}
|
||||
private Map<String,Object> encode(DownedEpisode value){var row=new LinkedHashMap<String,Object>();row.put("id",value.id().toString());row.put("character",value.character().toString());row.put("player",value.player().toString());row.put("session",value.session().toString());row.put("world",value.world().toString());row.put("x",BigDecimal.valueOf(value.x()).toPlainString());row.put("y",BigDecimal.valueOf(value.y()).toPlainString());row.put("z",BigDecimal.valueOf(value.z()).toPlainString());row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));row.put("entry-world",value.entryWorld().toString());row.put("entry-x",BigDecimal.valueOf(value.entryX()).toPlainString());row.put("entry-y",BigDecimal.valueOf(value.entryY()).toPlainString());row.put("entry-z",BigDecimal.valueOf(value.entryZ()).toPlainString());row.put("entry-yaw",Float.toString(value.entryYaw()));row.put("entry-pitch",Float.toString(value.entryPitch()));row.put("downed-at",value.downedAt().toString());row.put("expires-at",value.expiresAt().toString());row.put("attacker",value.attribution().attacker()==null?null:value.attribution().attacker().toString());row.put("cause",value.attribution().cause());row.put("player-defeat",value.attribution().playerDefeat());row.put("contributors",value.attribution().contributors().stream().map(UUID::toString).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("victim-level",value.attribution().victimLevel());row.put("victim-coins",value.attribution().victimCoins().toString());row.put("character-name",value.characterName());row.put("player-name",value.playerName());row.put("character-revision",value.characterRevision()==null?null:value.characterRevision().toString());row.put("phase",value.phase().name());row.put("outcome",value.outcome()==null?null:value.outcome().name());row.put("outcome-operation",value.outcomeOperation()==null?null:value.outcomeOperation().toString());row.put("resolved-at",value.resolvedAt()==null?null:value.resolvedAt().toString());row.put("protection-until",value.protectionUntil()==null?null:value.protectionUntil().toString());var decision=value.decision();row.put("decision-source",decision==null?null:decision.source());row.put("decision-actor-player",decision==null||decision.actorPlayer()==null?null:decision.actorPlayer().toString());row.put("decision-actor-player-name",decision==null?null:decision.actorPlayerName());row.put("decision-actor-character",decision==null||decision.actorCharacter()==null?null:decision.actorCharacter().toString());row.put("decision-actor-character-name",decision==null?null:decision.actorCharacterName());row.put("decision-actor-session",decision==null||decision.actorSession()==null?null:decision.actorSession().toString());row.put("decision-action",decision==null?null:decision.actionId().toString());row.put("decision-cast-at",decision==null||decision.castAt()==null?null:decision.castAt().toString());row.put("decision-cooldown-until",decision==null||decision.cooldownUntil()==null?null:decision.cooldownUntil().toString());row.put("decision-final-range",decision==null||decision.finalRange()==null?null:Double.toString(decision.finalRange()));row.put("decision-line-of-sight",decision==null?null:decision.lineOfSight());var phoenix=decision==null?null:decision.phoenix();row.put("decision-phoenix-quantity-before",phoenix==null?null:phoenix.quantityBefore().toString());row.put("decision-phoenix-quantity-after",phoenix==null?null:phoenix.quantityAfter().toString());row.put("decision-phoenix-health-before",phoenix==null?null:phoenix.healthBefore().toPlainString());row.put("decision-phoenix-health-after",phoenix==null?null:phoenix.healthAfter().toPlainString());row.put("decision-phoenix-maximum",phoenix==null?null:phoenix.maximum().toPlainString());row.put("decision-phoenix-entry-point",phoenix==null?null:phoenix.entryPoint());row.put("final-rewards",value.finalRewards().stream().map(reward->{var saved=new LinkedHashMap<String,Object>();saved.put("character",reward.character().toString());saved.put("experience",reward.experience().toString());saved.put("coins",reward.coins().toString());saved.put("unit-id",reward.unitId());saved.put("unit-count",reward.unitCount());return saved;}).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("published",value.published());return row;}
|
||||
private DownedEpisode decode(Map<?,?> row){
|
||||
String attacker=optional(row,"attacker"),outcome=optional(row,"outcome"),operation=optional(row,"outcome-operation"),resolved=optional(row,"resolved-at"),protection=optional(row,"protection-until"),revision=optional(row,"character-revision"),characterName=optional(row,"character-name"),playerName=optional(row,"player-name"),decisionSource=optional(row,"decision-source"),decisionPlayer=optional(row,"decision-actor-player"),decisionPlayerName=optional(row,"decision-actor-player-name"),decisionCharacter=optional(row,"decision-actor-character"),decisionCharacterName=optional(row,"decision-actor-character-name"),decisionSession=optional(row,"decision-actor-session"),decisionAction=optional(row,"decision-action"),decisionCastAt=optional(row,"decision-cast-at"),decisionCooldown=optional(row,"decision-cooldown-until"),decisionRange=optional(row,"decision-final-range");
|
||||
UUID world=uuid(row,"world"),entryWorld=row.containsKey("entry-world")?uuid(row,"entry-world"):world;
|
||||
@@ -41,7 +41,7 @@ public final class DownedDirectory implements AutoCloseable{
|
||||
float yaw=Float.parseFloat(text(row,"yaw")),pitch=Float.parseFloat(text(row,"pitch"));
|
||||
var phoenixQuantity=optional(row,"decision-phoenix-quantity-before");var phoenix=phoenixQuantity==null?null:new DownedEpisode.Phoenix(new java.math.BigInteger(phoenixQuantity),new java.math.BigInteger(text(row,"decision-phoenix-quantity-after")),new java.math.BigDecimal(text(row,"decision-phoenix-health-before")),new java.math.BigDecimal(text(row,"decision-phoenix-health-after")),new java.math.BigDecimal(text(row,"decision-phoenix-maximum")),text(row,"decision-phoenix-entry-point"));
|
||||
var decision=decisionSource==null?null:new DownedEpisode.Decision(decisionSource,decisionPlayer==null?null:UUID.fromString(decisionPlayer),decisionPlayerName,decisionCharacter==null?null:UUID.fromString(decisionCharacter),decisionCharacterName,decisionSession==null?null:UUID.fromString(decisionSession),UUID.fromString(decisionAction),decisionCastAt==null?null:Instant.parse(decisionCastAt),decisionCooldown==null?null:Instant.parse(decisionCooldown),decisionRange==null?null:Double.valueOf(decisionRange),row.get("decision-line-of-sight")==null?null:Boolean.valueOf(String.valueOf(row.get("decision-line-of-sight"))),phoenix);
|
||||
return new DownedEpisode(uuid(row,"id"),uuid(row,"character"),uuid(row,"player"),uuid(row,"session"),world,x,y,z,yaw,pitch,entryWorld,row.containsKey("entry-x")?Double.parseDouble(text(row,"entry-x")):x,row.containsKey("entry-y")?Double.parseDouble(text(row,"entry-y")):y,row.containsKey("entry-z")?Double.parseDouble(text(row,"entry-z")):z,row.containsKey("entry-yaw")?Float.parseFloat(text(row,"entry-yaw")):yaw,row.containsKey("entry-pitch")?Float.parseFloat(text(row,"entry-pitch")):pitch,Instant.parse(text(row,"downed-at")),Instant.parse(text(row,"expires-at")),new DownedEpisode.Attribution(attacker==null?null:UUID.fromString(attacker),text(row,"cause"),Boolean.parseBoolean(String.valueOf(row.containsKey("player-defeat")?row.get("player-defeat"):false)),row.containsKey("contributors")?list(row.get("contributors")).stream().map(String::valueOf).map(UUID::fromString).toList():List.of(),row.containsKey("victim-level")?Integer.parseInt(String.valueOf(row.get("victim-level"))):0,row.containsKey("victim-coins")?new java.math.BigInteger(String.valueOf(row.get("victim-coins"))):java.math.BigInteger.ZERO),characterName,playerName,revision==null?null:new java.math.BigInteger(revision),DownedEpisode.Phase.valueOf(text(row,"phase")),outcome==null?null:DownedEpisode.Outcome.valueOf(outcome),operation==null?null:UUID.fromString(operation),resolved==null?null:Instant.parse(resolved),protection==null?null:Instant.parse(protection),decision,list(row.get("final-rewards")).stream().map(DownedDirectory::map).map(reward->new DownedEpisode.Reward(uuid(reward,"character"),new java.math.BigInteger(text(reward,"experience")),new java.math.BigInteger(text(reward,"coins")))).toList(),Boolean.parseBoolean(String.valueOf(row.containsKey("published")?row.get("published"):false)));
|
||||
return new DownedEpisode(uuid(row,"id"),uuid(row,"character"),uuid(row,"player"),uuid(row,"session"),world,x,y,z,yaw,pitch,entryWorld,row.containsKey("entry-x")?Double.parseDouble(text(row,"entry-x")):x,row.containsKey("entry-y")?Double.parseDouble(text(row,"entry-y")):y,row.containsKey("entry-z")?Double.parseDouble(text(row,"entry-z")):z,row.containsKey("entry-yaw")?Float.parseFloat(text(row,"entry-yaw")):yaw,row.containsKey("entry-pitch")?Float.parseFloat(text(row,"entry-pitch")):pitch,Instant.parse(text(row,"downed-at")),Instant.parse(text(row,"expires-at")),new DownedEpisode.Attribution(attacker==null?null:UUID.fromString(attacker),text(row,"cause"),Boolean.parseBoolean(String.valueOf(row.containsKey("player-defeat")?row.get("player-defeat"):false)),row.containsKey("contributors")?list(row.get("contributors")).stream().map(String::valueOf).map(UUID::fromString).toList():List.of(),row.containsKey("victim-level")?Integer.parseInt(String.valueOf(row.get("victim-level"))):0,row.containsKey("victim-coins")?new java.math.BigInteger(String.valueOf(row.get("victim-coins"))):java.math.BigInteger.ZERO),characterName,playerName,revision==null?null:new java.math.BigInteger(revision),DownedEpisode.Phase.valueOf(text(row,"phase")),outcome==null?null:DownedEpisode.Outcome.valueOf(outcome),operation==null?null:UUID.fromString(operation),resolved==null?null:Instant.parse(resolved),protection==null?null:Instant.parse(protection),decision,list(row.get("final-rewards")).stream().map(DownedDirectory::map).map(reward->new DownedEpisode.Reward(uuid(reward,"character"),new java.math.BigInteger(text(reward,"experience")),new java.math.BigInteger(text(reward,"coins")),reward.containsKey("unit-id")?text(reward,"unit-id"):"legacy:"+text(reward,"character"),reward.containsKey("unit-count")?Integer.parseInt(String.valueOf(reward.get("unit-count"))):1)).toList(),Boolean.parseBoolean(String.valueOf(row.containsKey("published")?row.get("published"):false)));
|
||||
}
|
||||
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);options.setCodePointLimit(1_048_576);return new Yaml(new SafeConstructor(options));}private static List<?> list(Object value){return value==null?List.of():(List<?>)value;}private static Map<?,?> map(Object value){return(Map<?,?>)value;}private static String text(Map<?,?> map,String key){Object value=map.get(key);if(!(value instanceof String text)||text.isBlank())throw new IllegalArgumentException(key);return text;}private static String optional(Map<?,?> map,String key){Object value=map.get(key);return value==null?null:text(map,key);}private static UUID uuid(Map<?,?> map,String key){return UUID.fromString(text(map,key));}
|
||||
private <T>CompletableFuture<T> submit(Callable<T> work){var result=new CompletableFuture<T>();synchronized(this){if(closed){result.completeExceptionally(new IOException("Downed directory closed"));return result;}pending++;}worker.execute(()->{try{result.complete(work.call());}catch(Throwable failure){result.completeExceptionally(failure);}finally{finished();}});return result;}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);}
|
||||
|
||||
@@ -19,12 +19,14 @@ public record DownedEpisode(UUID id, UUID character, UUID player, UUID session,
|
||||
public enum Phase { PENDING, RESOLVED }
|
||||
public enum Outcome { REVIVED, FINAL_DEATH }
|
||||
|
||||
public record Reward(UUID character, BigInteger experience, BigInteger coins) {
|
||||
public record Reward(UUID character, BigInteger experience, BigInteger coins,String unitId,int unitCount) {
|
||||
public Reward(UUID character,BigInteger experience,BigInteger coins){this(character,experience,coins,"legacy:"+character,1);}
|
||||
public Reward {
|
||||
Objects.requireNonNull(character);
|
||||
Objects.requireNonNull(experience);
|
||||
Objects.requireNonNull(coins);
|
||||
if (experience.signum() < 0 || coins.signum() < 0) {
|
||||
Objects.requireNonNull(unitId);
|
||||
if (experience.signum() < 0 || coins.signum() < 0 || unitId.isBlank() || unitCount < 1) {
|
||||
throw new IllegalArgumentException("Invalid downed reward");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ 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;
|
||||
private games.dmg.fantasystick.ability.FrostShard frostShard;
|
||||
@@ -65,14 +68,17 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.ability.Meteor meteor;
|
||||
private AdminCharacterIssuer characterIssuer;
|
||||
private AdminRevival adminRevival;
|
||||
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
|
||||
private AdminExperienceGrant experienceGrant;
|
||||
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
|
||||
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
|
||||
private games.dmg.fantasystick.events.SwitchConfigEventBridge switchConfigEvents;
|
||||
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
|
||||
private games.dmg.fantasystick.persistence.PartySettings partySettings;
|
||||
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
|
||||
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
|
||||
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
|
||||
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
|
||||
private games.dmg.fantasystick.persistence.HardMobXpSettings hardMobXpSettings;
|
||||
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
|
||||
@@ -84,6 +90,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private OutOfCombatRecovery outOfCombatRecovery;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationI quickerRegeneration;
|
||||
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationIISettings quickerRegenerationIISettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
|
||||
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
|
||||
@@ -91,6 +98,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
|
||||
private games.dmg.fantasystick.party.PartyRegistrarDirectory partyRegistrars;
|
||||
private games.dmg.fantasystick.party.PartyRegistrarController partyRegistrarController;
|
||||
private games.dmg.fantasystick.events.PartyEventBridge partyEvents;
|
||||
private games.dmg.fantasystick.training.TrainingDummyDirectory trainingDummies;
|
||||
private games.dmg.fantasystick.training.TrainingDummyController trainingDummyController;
|
||||
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
|
||||
@@ -105,6 +113,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) {
|
||||
@@ -329,11 +338,17 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachSwitchConfigEvents(games.dmg.fantasystick.events.SwitchConfigEventBridge events){
|
||||
if(closed||switchDelaySettings==null||switchConfigEvents!=null)throw new IllegalStateException("Switch config events unavailable");
|
||||
switchConfigEvents=Objects.requireNonNull(events);
|
||||
}
|
||||
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
|
||||
public void attachPartyEconomySettings(games.dmg.fantasystick.persistence.PartyEconomySettings settings){if(closed||partyEconomySettings!=null)throw new IllegalStateException("Party economy settings already attached or command closed");partyEconomySettings=Objects.requireNonNull(settings);}
|
||||
public void attachGearSettings(games.dmg.fantasystick.persistence.GearSettings settings){if(closed||gearSettings!=null)throw new IllegalStateException("Gear settings already attached or command closed");gearSettings=Objects.requireNonNull(settings);}
|
||||
public void attachCoinSettings(games.dmg.fantasystick.persistence.CoinSettings settings){if(closed||coinSettings!=null)throw new IllegalStateException("Coin settings already attached or command closed");coinSettings=Objects.requireNonNull(settings);}
|
||||
public void attachCoinEvents(games.dmg.fantasystick.events.CoinEventBridge events){if(closed||coinEvents!=null)throw new IllegalStateException("Coin events already attached or command closed");coinEvents=Objects.requireNonNull(events);}
|
||||
public void attachAbilityConfigEvents(games.dmg.fantasystick.events.AbilityConfigEventBridge events){if(closed||abilityConfigEvents!=null)throw new IllegalStateException("Ability config events already attached or command closed");abilityConfigEvents=Objects.requireNonNull(events);}
|
||||
public void attachHardMobXpSettings(games.dmg.fantasystick.persistence.HardMobXpSettings settings,
|
||||
games.dmg.fantasystick.party.CombatExperience experience) {
|
||||
if (closed || hardMobXpSettings != null) throw new IllegalStateException("Hard-mob XP settings already attached or command closed");
|
||||
@@ -353,6 +368,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
outOfCombatRecovery = Objects.requireNonNull(recovery);
|
||||
}
|
||||
|
||||
public void attachPassiveEvents(games.dmg.fantasystick.events.PassiveEventBridge events){if(closed||passiveEvents!=null)throw new IllegalStateException("Passive events already attached or command closed");passiveEvents=Objects.requireNonNull(events);}
|
||||
|
||||
public void attachQuickerRegeneration(games.dmg.fantasystick.persistence.QuickerRegenerationSettings settings,
|
||||
games.dmg.fantasystick.passive.QuickerRegenerationI passive,
|
||||
games.dmg.fantasystick.passive.PassiveRegistry registry) {
|
||||
@@ -387,11 +404,23 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
adminRevival=Objects.requireNonNull(revival);adminReviveSettings=Objects.requireNonNull(settings);
|
||||
}
|
||||
|
||||
public void attachAdminRevivalEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events) {
|
||||
if (closed || adminRevivalEvents != null || adminRevival == null)
|
||||
throw new IllegalStateException("Admin revival event bridge unavailable");
|
||||
adminRevivalEvents = Objects.requireNonNull(events);
|
||||
adminRevival.attachEvents(events);
|
||||
}
|
||||
|
||||
public void attachCharacterIssuer(AdminCharacterIssuer issuer) {
|
||||
if (closed || characterIssuer != null) throw new IllegalStateException("Character issuer already attached or command closed");
|
||||
characterIssuer = Objects.requireNonNull(issuer);
|
||||
}
|
||||
|
||||
public void attachCharacterIssueEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
|
||||
if (closed || characterIssuer == null) throw new IllegalStateException("Character issuer unavailable");
|
||||
characterIssuer.attachIdentityEvents(events);
|
||||
}
|
||||
|
||||
public void attachCharacterBooks(games.dmg.fantasystick.book.CharacterBookController books,
|
||||
games.dmg.fantasystick.persistence.CharacterStorage storage) {
|
||||
if (closed || characterBooks != null) throw new IllegalStateException("Character books already attached or command closed");
|
||||
@@ -404,12 +433,25 @@ 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,
|
||||
games.dmg.fantasystick.party.PartyRegistrarController controller) {
|
||||
if (closed || partyRegistrars != null) throw new IllegalStateException("Party Registrars already attached or command closed");
|
||||
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");
|
||||
@@ -431,6 +473,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;
|
||||
}
|
||||
@@ -487,24 +536,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;
|
||||
}
|
||||
@@ -520,9 +586,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"));
|
||||
@@ -882,8 +957,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
private void placeRegistrarResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.party.PartyRegistrar saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"party-registrar-place",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");return;}try{int chunkX=location.getBlockX()>>4,chunkZ=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(location.getWorld().getChunkAt(chunkX,chunkZ));if(player.isOnline())player.sendMessage("Placed Party Registrar "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"party-registrar-entity",entityFailure);partyRegistrars.remove(saved.id()).whenComplete((ignored,rollbackFailure)->{if(rollbackFailure!=null)report(player.getUniqueId(),"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{partyRegistrarController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");});});}});}catch(RuntimeException rejected){report(player.getUniqueId(),"party-registrar-response",rejected);}}
|
||||
private void removeRegistrarResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.party.PartyRegistrar> removed,Throwable failure){if(failure!=null)report(null,"party-registrar-remove",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null)sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Party Registrar not found.");else try{partyRegistrarController.removeLoaded(id);sender.sendMessage("Removed Party Registrar "+id+".");}catch(RuntimeException entityFailure){report(null,"party-registrar-entity",entityFailure);partyRegistrars.restore(removed.orElseThrow()).whenComplete((restored,rollbackFailure)->{if(rollbackFailure!=null)report(null,"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{if(rollbackFailure==null){var world=server.getWorld(restored.world());int chunkX=((int)Math.floor(restored.x()))>>4,chunkZ=((int)Math.floor(restored.z()))>>4;if(world!=null&&world.isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(world.getChunkAt(chunkX,chunkZ));}sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");});});}});}catch(RuntimeException rejected){report(null,"party-registrar-response",rejected);}}
|
||||
private void placeRegistrarResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.party.PartyRegistrar saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"party-registrar-place",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");return;}try{int chunkX=location.getBlockX()>>4,chunkZ=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(location.getWorld().getChunkAt(chunkX,chunkZ));if(partyEvents!=null)partyEvents.registrarPlaced("player",player.getUniqueId(),player.getName(),saved,partyRegistrarController.loadedEntity(saved.id()).orElse(null),partyRegistrarController.loadedEntity(saved.id()).isPresent());if(player.isOnline())player.sendMessage("Placed Party Registrar "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"party-registrar-entity",entityFailure);partyRegistrars.remove(saved.id()).whenComplete((ignored,rollbackFailure)->{if(rollbackFailure!=null)report(player.getUniqueId(),"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{partyRegistrarController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");});});}});}catch(RuntimeException rejected){report(player.getUniqueId(),"party-registrar-response",rejected);}}
|
||||
private void removeRegistrarResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.party.PartyRegistrar> removed,Throwable failure){if(failure!=null)report(null,"party-registrar-remove",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null)sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Party Registrar not found.");else try{var loaded=partyRegistrarController.loadedEntity(id);partyRegistrarController.removeLoaded(id);if(partyEvents!=null){String actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;partyEvents.registrarRemoved(actorKind,actorPlayer,actorName,removed.orElseThrow(),loaded.orElse(null),false);}sender.sendMessage("Removed Party Registrar "+id+".");}catch(RuntimeException entityFailure){report(null,"party-registrar-entity",entityFailure);partyRegistrars.restore(removed.orElseThrow()).whenComplete((restored,rollbackFailure)->{if(rollbackFailure!=null)report(null,"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{if(rollbackFailure==null){var world=server.getWorld(restored.world());int chunkX=((int)Math.floor(restored.x()))>>4,chunkZ=((int)Math.floor(restored.z()))>>4;if(world!=null&&world.isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(world.getChunkAt(chunkX,chunkZ));}sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");});});}});}catch(RuntimeException rejected){report(null,"party-registrar-response",rejected);}}
|
||||
private void partyRegistrar(CommandSender sender,String[] args){
|
||||
if(partyRegistrars==null||partyRegistrarController==null||args.length<2){sender.sendMessage("Usage: /fstickadmin party-registrar <place [name...]|remove <registrar-uuid>|list>");return;}
|
||||
if(args[1].equals("list")&&args.length==2){var values=partyRegistrars.all();if(values.isEmpty())sender.sendMessage("No Party Registrars are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Party Registrar":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}
|
||||
@@ -902,7 +977,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
sender.sendMessage("Character issue refused: target must name one online player. No character or item was created."); return;
|
||||
}
|
||||
var target = matches.getFirst();
|
||||
characterIssuer.issue(target, outcome -> {
|
||||
characterIssuer.issue(sender, target, outcome -> {
|
||||
if (closed || !sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(switch (outcome) {
|
||||
@@ -1001,12 +1076,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (fireballSettings != null && games.dmg.fantasystick.persistence.FireballSettings.keys().contains(args[1])) {
|
||||
fireballConfiguration(sender, args); return;
|
||||
}
|
||||
if (greatSwordSettings != null && games.dmg.fantasystick.persistence.GreatSwordSettings.keys().contains(args[1])) {
|
||||
greatSwordConfiguration(sender, args); return;
|
||||
}
|
||||
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
|
||||
configurationUsage(sender); return;
|
||||
}
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = frostSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + frostSettings.value(key) + " (applies to future Frost Shard casts only)");
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "frost_shard", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Frost Shard casts only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostShardSettings.Update> operation;
|
||||
@@ -1020,25 +1101,63 @@ 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);
|
||||
String before = fireballSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + fireballSettings.value(key) + " (applies to future Fireball casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "fireball", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Fireball casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FireballSettings.Update> operation;
|
||||
try { operation = fireballSettings.set(key, args[2]); }
|
||||
@@ -1052,6 +1171,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
if (failure == null && fireballSettings.publish(saved)) {
|
||||
fireball.configure(fireballSettings.settings()); abilities.replace(fireball.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "fireball", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
@@ -1096,9 +1216,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
private void quickerRegenerationConfiguration(CommandSender sender, String[] args) {
|
||||
String key=games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY;String before=quickerRegenerationSettings.value();String actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY + "="
|
||||
+ quickerRegenerationSettings.value() + " (applies to future recovery pulses only)"); return;
|
||||
if(passiveEvents!=null)passiveEvents.configRead(actorKind,actorPlayer,actorName,key,before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future recovery pulses only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickerRegenerationSettings.Update> operation;
|
||||
try { operation = quickerRegenerationSettings.set(args[2]); }
|
||||
@@ -1111,7 +1232,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return; boolean applied = false;
|
||||
if (failure == null) {
|
||||
applied = quickerRegenerationSettings.publish(saved);
|
||||
if (applied) { quickerRegeneration.configure(saved.settings()); passiveRegistry.replace(quickerRegeneration.definition()); }
|
||||
if (applied) { quickerRegeneration.configure(saved.settings()); passiveRegistry.replace(quickerRegeneration.definition());if(passiveEvents!=null)passiveEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value()); }
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
@@ -1184,8 +1305,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void longbowConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = longbowSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + longbowSettings.value(key) + " (applies to future Longbow shots only)");
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "longbow", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Longbow shots only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.LongbowSettings.Update> operation;
|
||||
@@ -1199,16 +1323,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
longbow.configure(saved.settings());
|
||||
boolean applied = failure == null && longbowSettings.publish(saved);
|
||||
if (applied) {
|
||||
longbow.configure(longbowSettings.settings());
|
||||
abilities.replace(longbow.definition());
|
||||
longbowSettings.publish(saved);
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "longbow", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Longbow shots; active shots and deadlines are unchanged."
|
||||
: "Longbow configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Longbow configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Longbow shots; active shots and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + longbowSettings.value(key) + ".");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
@@ -1216,8 +1342,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void quickstepConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = quickstepSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + quickstepSettings.value(key) + " (applies to future Quickstep dashes only)");
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "quickstep", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Quickstep dashes only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickstepSettings.Update> operation;
|
||||
@@ -1231,26 +1360,64 @@ 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];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = mendingLightSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + mendingLightSettings.value(key) + " (applies to future Mending Light casts only)");
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "mending_light", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Mending Light casts only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.MendingLightSettings.Update> operation;
|
||||
@@ -1263,8 +1430,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
mendingLight.configure(saved.settings()); abilities.replace(mendingLight.definition()); mendingLightSettings.publish(saved);
|
||||
if (failure == null && mendingLightSettings.publish(saved)) {
|
||||
mendingLight.configure(saved.settings()); abilities.replace(mendingLight.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "mending_light", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
@@ -1277,8 +1445,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void purifyConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = purifySettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + purifySettings.value(key) + " (applies to future Purify casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "purify", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Purify casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PurifySettings.Update> operation;
|
||||
try { operation = purifySettings.set(key, args[2]); }
|
||||
@@ -1290,8 +1461,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
purify.configure(saved.settings()); abilities.replace(purify.definition()); purifySettings.publish(saved);
|
||||
if (failure == null && purifySettings.publish(saved)) {
|
||||
purify.configure(saved.settings()); abilities.replace(purify.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "purify", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
@@ -1304,8 +1476,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void guardianBondConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = guardianBondSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + guardianBondSettings.value(key) + " (applies to future Guardian Bond casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "guardian_bond", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Guardian Bond casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GuardianBondSettings.Update> operation;
|
||||
try { operation = guardianBondSettings.set(key, args[2]); }
|
||||
@@ -1317,8 +1492,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
guardianBond.configure(saved.settings()); abilities.replace(guardianBond.definition()); guardianBondSettings.publish(saved);
|
||||
if (failure == null && guardianBondSettings.publish(saved)) {
|
||||
guardianBond.configure(saved.settings()); abilities.replace(guardianBond.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "guardian_bond", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
@@ -1331,8 +1507,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void rallyingCryConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = rallyingCrySettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + rallyingCrySettings.value(key) + " (applies to future Rallying Cry casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "rallying_cry", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Rallying Cry casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RallyingCrySettings.Update> operation;
|
||||
try { operation = rallyingCrySettings.set(key, args[2]); }
|
||||
@@ -1344,8 +1523,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
rallyingCry.configure(saved.settings()); abilities.replace(rallyingCry.definition()); rallyingCrySettings.publish(saved);
|
||||
if (failure == null && rallyingCrySettings.publish(saved)) {
|
||||
rallyingCry.configure(saved.settings()); abilities.replace(rallyingCry.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "rallying_cry", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
@@ -1358,8 +1538,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void frostNovaConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = frostNovaSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + frostNovaSettings.value(key) + " (applies to future Frost Nova casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "frost_nova", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Frost Nova casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostNovaSettings.Update> operation;
|
||||
try { operation = frostNovaSettings.set(key, args[2]); }
|
||||
@@ -1371,12 +1554,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
frostNova.configure(saved.settings()); abilities.replace(frostNova.definition()); frostNovaSettings.publish(saved);
|
||||
boolean published = failure == null && frostNovaSettings.publish(saved);
|
||||
if (published) {
|
||||
frostNova.configure(saved.settings()); abilities.replace(frostNova.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "frost_nova", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
sender.sendMessage(published
|
||||
? "Saved " + key + "=" + saved.value() + " for future Frost Nova casts; active slows and deadlines are unchanged."
|
||||
: "Frost Nova configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
@@ -1385,8 +1570,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void chainLightningConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = chainLightningSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + chainLightningSettings.value(key) + " (applies to future Chain Lightning casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "chain_lightning", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Chain Lightning casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ChainLightningSettings.Update> operation;
|
||||
try { operation = chainLightningSettings.set(key, args[2]); }
|
||||
@@ -1398,12 +1586,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
chainLightning.configure(saved.settings()); abilities.replace(chainLightning.definition()); chainLightningSettings.publish(saved);
|
||||
boolean published = failure == null && chainLightningSettings.publish(saved);
|
||||
if (published) {
|
||||
chainLightning.configure(saved.settings()); abilities.replace(chainLightning.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "chain_lightning", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
sender.sendMessage(published
|
||||
? "Saved " + key + "=" + saved.value() + " for future Chain Lightning casts; active chains and deadlines are unchanged."
|
||||
: "Chain Lightning configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
@@ -1412,8 +1602,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void sanctuaryConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = sanctuarySettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + sanctuarySettings.value(key) + " (applies to future Sanctuary casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "sanctuary", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Sanctuary casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SanctuarySettings.Update> operation;
|
||||
try { operation = sanctuarySettings.set(key, args[2]); }
|
||||
@@ -1425,12 +1618,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
sanctuary.configure(saved.settings()); abilities.replace(sanctuary.definition()); sanctuarySettings.publish(saved);
|
||||
boolean published = failure == null && sanctuarySettings.publish(saved);
|
||||
if (published) {
|
||||
sanctuary.configure(saved.settings()); abilities.replace(sanctuary.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "sanctuary", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
sender.sendMessage(published
|
||||
? "Saved " + key + "=" + saved.value() + " for future Sanctuary casts; active areas and deadlines are unchanged."
|
||||
: "Sanctuary configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
@@ -1439,7 +1634,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void warBannerConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) { sender.sendMessage(key + "=" + warBannerSettings.value(key) + " (applies to future War Banner casts only)"); return; }
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = warBannerSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "war_banner", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future War Banner casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.WarBannerSettings.Update> operation;
|
||||
try { operation = warBannerSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) { sender.sendMessage("War Banner setting is invalid; the effective value is unchanged."); return; }
|
||||
@@ -1447,9 +1647,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (failure != null) report(null, "config-" + key, failure); if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) { warBanner.configure(saved.settings()); abilities.replace(warBanner.definition()); warBannerSettings.publish(saved); }
|
||||
boolean published = failure == null && warBannerSettings.publish(saved);
|
||||
if (published) {
|
||||
warBanner.configure(saved.settings()); abilities.replace(warBanner.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "war_banner", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION) || sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
sender.sendMessage(published
|
||||
? "Saved " + key + "=" + saved.value() + " for future War Banner casts; active areas and deadlines are unchanged."
|
||||
: "War Banner configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
@@ -1457,26 +1661,79 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
private void meteorConfiguration(CommandSender sender, String[] args) {
|
||||
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+meteorSettings.value(key)+" (applies to future Meteor casts only)");return;}
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = meteorSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "meteor", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Meteor casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.MeteorSettings.Update> operation;
|
||||
try{operation=meteorSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Meteor 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){meteor.configure(saved.settings());abilities.replace(meteor.definition());meteorSettings.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 Meteor casts; active warnings and deadlines are unchanged.":"Meteor configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
|
||||
try { operation = meteorSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Meteor 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 published = failure == null && meteorSettings.publish(saved);
|
||||
if (published) {
|
||||
meteor.configure(saved.settings()); abilities.replace(meteor.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "meteor", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(published
|
||||
? "Saved " + key + "=" + saved.value() + " for future Meteor casts; active warnings and deadlines are unchanged."
|
||||
: "Meteor configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
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; }
|
||||
@@ -1485,22 +1742,29 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
shieldBash.configure(saved.settings()); abilities.replace(shieldBash.definition()); shieldBashSettings.publish(saved);
|
||||
String effectiveBefore = failure == null ? shieldBashSettings.value(key) : null;
|
||||
boolean applied = failure == null && shieldBashSettings.publish(saved);
|
||||
if (applied) {
|
||||
shieldBash.configure(shieldBashSettings.settings()); abilities.replace(shieldBash.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "shield_bash", key, effectiveBefore, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Shield Bash strikes; active work and deadlines are unchanged."
|
||||
: "Shield Bash configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Shield Bash configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Shield Bash strikes; active work and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + shieldBashSettings.value(key) + ".");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void stoneSkinConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = stoneSkinSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + stoneSkinSettings.value(key) + " (applies to future Stone Skin casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "stone_skin", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Stone Skin casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.StoneSkinSettings.Update> operation;
|
||||
try { operation = stoneSkinSettings.set(key, args[2]); }
|
||||
@@ -1510,22 +1774,29 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
stoneSkin.configure(saved.settings()); abilities.replace(stoneSkin.definition()); stoneSkinSettings.publish(saved);
|
||||
String effectiveBefore = failure == null ? stoneSkinSettings.value(key) : null;
|
||||
boolean applied = failure == null && stoneSkinSettings.publish(saved);
|
||||
if (applied) {
|
||||
stoneSkin.configure(stoneSkinSettings.settings()); abilities.replace(stoneSkin.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "stone_skin", key, effectiveBefore, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Stone Skin casts; active effects and deadlines are unchanged."
|
||||
: "Stone Skin configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Stone Skin configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Stone Skin casts; active effects and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + stoneSkinSettings.value(key) + ".");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void secondWindConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = secondWindSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + secondWindSettings.value(key) + " (applies to future Second Wind casts only)");
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "second_wind", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Second Wind casts only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SecondWindSettings.Update> operation;
|
||||
@@ -1539,15 +1810,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
secondWind.configure(saved.settings()); abilities.replace(secondWind.definition());
|
||||
secondWindSettings.publish(saved);
|
||||
String effectiveBefore = failure == null ? secondWindSettings.value(key) : null;
|
||||
boolean applied = failure == null && secondWindSettings.publish(saved);
|
||||
if (applied) {
|
||||
secondWind.configure(secondWindSettings.settings()); abilities.replace(secondWind.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "second_wind", key, effectiveBefore, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Second Wind casts; active work and deadlines are unchanged."
|
||||
: "Second Wind configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Second Wind configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Second Wind casts; active work and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + secondWindSettings.value(key) + ".");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
@@ -1555,13 +1829,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void sheetResourcePackConfiguration(CommandSender sender,String[] args){if(sheetResourcePackSettings==null){configurationUsage(sender);return;}String key=args[1];if(args.length==2){sender.sendMessage(key+"="+sheetResourcePackSettings.value(key));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SheetResourcePackSettings.Update> operation;try{operation=sheetResourcePackSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Sheet resource-pack value is invalid; use an absolute HTTPS URL or 40 hexadecimal SHA-1 characters.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-sheet-resource-pack",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=sheetResourcePackSettings.publish(saved);if(applied)sheetResourcePack.deliverAll(server.getOnlinePlayers());}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Sheet resource-pack configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+sheetResourcePackSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+sheetResourcePackSettings.value(key)+"; optional delivery was requested for online players.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void partyEconomyConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+partyEconomySettings.value(key)+" (applies to future party transactions only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartyEconomySettings.Update> operation;try{operation=partyEconomySettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party price is invalid; enter a whole number from 0 through 2147483647.");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&&partyEconomySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party price save failed; the effective price is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+partyEconomySettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future party transactions.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
private void partyEconomyConfiguration(CommandSender sender,String[] args){String key=args[1],before=partyEconomySettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(partyEvents!=null)partyEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future party transactions only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartyEconomySettings.Update> operation;try{operation=partyEconomySettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party price is invalid; enter a whole number from 0 through 2147483647.");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&&partyEconomySettings.publish(saved);if(applied&&partyEvents!=null)partyEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party price save failed; the effective price is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+partyEconomySettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future party transactions.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void gearConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+gearSettings.value(key)+" (applies to the next armor restock only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GearSettings.Update> operation;try{operation=gearSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Armor market setting is invalid; variance is 0–100, prices are nonnegative, and positive stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&gearSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Armor market setting save failed; effective settings and the current catalog are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+gearSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for the next automatic or administrative restock; the current catalog is unchanged.");});});}
|
||||
private void rareConfiguration(CommandSender sender,String[] args){String key=args[1],before=rareSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;boolean phoenix=key.contains("phoenix-down");if(args.length==2){if(phoenix&&downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+(key.endsWith("-healing-percent")||key.equals(games.dmg.fantasystick.persistence.RareSettings.PHOENIX_PROTECTION)?" (applies to future accepted uses)":" (applies to the next rare restock only)"));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RareSettings.Update> operation;try{operation=rareSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Rare setting is invalid; percentages are 0–100, values are nonnegative, and stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&rareSettings.publish(saved);if(applied&&phoenix&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+rareSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; current catalog and accepted uses are unchanged.");});});}
|
||||
private void downedConfiguration(CommandSender sender,String[] args){String key=args[1],before=downedSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future downed outcomes only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.DownedSettings.Update> operation;try{operation=downedSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Downed setting is invalid; timeout must be positive and protection may be zero, both at most 365 days.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&downedSettings.publish(saved);if(applied&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Downed setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+downedSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; existing deadlines are unchanged.");});});}
|
||||
|
||||
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+coinSettings.value(key)+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");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&&coinSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1],before=coinSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(coinEvents!=null)coinEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");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&&coinSettings.publish(saved);if(applied&&coinEvents!=null)coinEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void hardMobXpConfiguration(CommandSender sender, String[] args) {
|
||||
String key = games.dmg.fantasystick.persistence.HardMobXpSettings.KEY;
|
||||
@@ -1601,27 +1875,107 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fstickadmin config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void switchDelayConfiguration(CommandSender sender,String[] args){if(switchDelaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+switchDelaySettings.value()+" (new exits use this delay; existing deadlines are unchanged)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;try{operation=switchDelaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-switch-delay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)switchDelaySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()+"; new exits use it and existing deadlines are unchanged.":"Character switch delay save failed; the effective value is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
private void switchDelayConfiguration(CommandSender sender,String[] args){
|
||||
if(switchDelaySettings==null){configurationUsage(sender);return;}
|
||||
var audit=switchConfigEvents;
|
||||
if(args.length==2){
|
||||
var effective=switchDelaySettings.value();
|
||||
if(audit!=null)audit.read(audit.actor(sender),effective);
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+effective
|
||||
+" (new exits use this delay; existing deadlines are unchanged)");
|
||||
return;
|
||||
}
|
||||
var actor=audit==null?null:audit.actor(sender);
|
||||
var operationId=java.util.UUID.randomUUID();
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;
|
||||
try{operation=switchDelaySettings.set(args[2]);}
|
||||
catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}
|
||||
operation.whenComplete((saved,failure)->{
|
||||
if(failure!=null)report(null,"config-switch-delay",failure);
|
||||
if(closed)return;
|
||||
try{nextTick.accept(()->{
|
||||
if(closed)return;
|
||||
if(failure==null){
|
||||
var before=switchDelaySettings.value();
|
||||
switchDelaySettings.publish(saved);
|
||||
var effective=switchDelaySettings.value();
|
||||
if(audit!=null&&!before.equals(effective))audit.changed(actor,operationId,before,effective,saved);
|
||||
}
|
||||
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
|
||||
sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()
|
||||
+"; new exits use it and existing deadlines are unchanged."
|
||||
:"Character switch delay save failed; the effective value is unchanged.");
|
||||
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
|
||||
});
|
||||
}
|
||||
|
||||
private void adminReviveConfiguration(CommandSender sender,String[] args){if(adminReviveSettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+adminReviveSettings.value()+" (applies to later admin revival selections only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdminReviveSettings.Update> operation;try{operation=adminReviveSettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Admin revival timeout is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-admin-revive",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)adminReviveSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+saved.value()+" for later selections; open selections retain their deadline.":"Admin revival configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
private void adminReviveConfiguration(CommandSender sender,String[] args){
|
||||
if(adminReviveSettings==null){configurationUsage(sender);return;}
|
||||
var audit = adminRevivalEvents;
|
||||
if(args.length==2){
|
||||
var value=adminReviveSettings.value();
|
||||
if(audit!=null)audit.read(audit.actor(sender),value);
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+value
|
||||
+" (applies to later admin revival selections only)");
|
||||
return;
|
||||
}
|
||||
var actor=audit==null?null:audit.actor(sender);
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdminReviveSettings.Update> operation;
|
||||
try{operation=adminReviveSettings.set(args[2]);}
|
||||
catch(IllegalArgumentException invalid){sender.sendMessage("Admin revival timeout is invalid; the effective value is unchanged.");return;}
|
||||
operation.whenComplete((saved,failure)->{
|
||||
if(failure!=null)report(null,"config-admin-revive",failure);
|
||||
if(closed)return;
|
||||
try{nextTick.accept(()->{
|
||||
if(closed)return;
|
||||
if(failure==null){
|
||||
var before=adminReviveSettings.value();
|
||||
adminReviveSettings.publish(saved);
|
||||
if(audit!=null && !before.equals(adminReviveSettings.value()))
|
||||
audit.changed(actor,before,saved);
|
||||
}
|
||||
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
|
||||
sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+saved.value()
|
||||
+" for later selections; open selections retain their deadline."
|
||||
:"Admin revival configuration save failed; the effective setting is unchanged.");
|
||||
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
|
||||
});
|
||||
}
|
||||
|
||||
private void adventureConfiguration(CommandSender sender, String[] args) {
|
||||
var action = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, null);
|
||||
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); }
|
||||
@@ -1709,6 +2063,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY);
|
||||
keys.add(games.dmg.fantasystick.persistence.HealthStealSettings.KEY);
|
||||
keys.addAll(games.dmg.fantasystick.persistence.FireballSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.GreatSwordSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
|
||||
@@ -1773,6 +2128,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
String defaults = games.dmg.fantasystick.persistence.FireballSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (greatSwordSettings != null && games.dmg.fantasystick.persistence.GreatSwordSettings.keys().contains(args[1])) {
|
||||
String current = greatSwordSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.GreatSwordSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (frostSettings != null && games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
|
||||
String current = frostSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(args[1]);
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private java.util.function.Function<UUID,DefeatCapture> defeatCapture=ignored->new DefeatCapture(false,List.of(),0,BigInteger.ZERO);
|
||||
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 =
|
||||
@@ -105,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;
|
||||
@@ -114,6 +122,27 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean uncertainSave;
|
||||
boolean nativeUncertain;
|
||||
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;
|
||||
@@ -206,6 +235,13 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public synchronized void attachDefeatCapture(java.util.function.Function<UUID,DefeatCapture> capture){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");defeatCapture=Objects.requireNonNull(capture);}
|
||||
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)));}
|
||||
|
||||
@@ -606,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))) {
|
||||
@@ -635,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.
|
||||
@@ -683,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;
|
||||
@@ -703,6 +755,10 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if(attempt.resumedRevive!=null){var recovered=attempt.resumedRevive;if(downedEvents!=null)downedEvents.outcomePublished(recovered);downedDirectory.publish(recovered.id(),recovered.outcomeOperation()).whenComplete((published,publishFailure)->{if(publishFailure!=null)diagnostic(attempt,publishFailure);else if(downedEvents!=null)downedEvents.outcomePublished(published);});}
|
||||
}
|
||||
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.");
|
||||
@@ -732,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);
|
||||
@@ -743,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);
|
||||
}
|
||||
@@ -780,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
|
||||
@@ -849,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{if(!attempt.player.teleport(attempt.entryLocation.clone()))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;}
|
||||
|
||||
@@ -985,6 +1050,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private void withdrawPresentation(Attempt attempt) {
|
||||
if (!attempt.presentationInstalled) return;
|
||||
attempt.presentationInstalled = false;
|
||||
if(sessionEvents!=null)sessionEvents.forget(attempt.owner,attempt.session);
|
||||
try { presentation.withdraw(attempt.owner, attempt.session); }
|
||||
catch (RuntimeException | LinkageError failure) { diagnostic(attempt, failure); }
|
||||
}
|
||||
@@ -1002,7 +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;
|
||||
@@ -1010,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.");
|
||||
@@ -1025,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);
|
||||
@@ -1058,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)),
|
||||
@@ -1083,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;
|
||||
}
|
||||
@@ -1117,15 +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");
|
||||
}
|
||||
});
|
||||
@@ -1231,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;
|
||||
@@ -1259,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;
|
||||
@@ -1275,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) {
|
||||
@@ -1286,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;
|
||||
@@ -1297,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)) {
|
||||
@@ -1304,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;
|
||||
}
|
||||
@@ -1313,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 {
|
||||
@@ -1345,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();
|
||||
|
||||
@@ -15,8 +15,13 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
|
||||
private final PlayerFeedbackPreferences preferences;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Throwable> failures;
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge events;
|
||||
private boolean closed;
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.PreferenceEventBridge bridge) {
|
||||
this.events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
public FantasyFeedbackCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
|
||||
Consumer<Throwable> failures) {
|
||||
this.preferences = Objects.requireNonNull(preferences);
|
||||
@@ -26,32 +31,51 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
if (events != null) try { events.refuseNonPlayer(sender, "combat_feedback"); }
|
||||
catch (RuntimeException ignored) { }
|
||||
sender.sendMessage("Fantasy feedback is a player-account preference.");
|
||||
return true;
|
||||
}
|
||||
if (args.length == 0) {
|
||||
var action = observe(player, null);
|
||||
player.sendMessage("Fantasy feedback is " + state(preferences.enabled(player.getUniqueId())) + ".");
|
||||
if (action != null) events.read(action);
|
||||
return true;
|
||||
}
|
||||
if (args.length != 1 || !(args[0].equals("on") || args[0].equals("off"))) return false;
|
||||
if (args.length != 1 || !(args[0].equals("on") || args[0].equals("off"))) {
|
||||
var action = observe(player, null);
|
||||
if (action != null) events.refused(action, "invalid_arguments");
|
||||
return false;
|
||||
}
|
||||
boolean enabled = args[0].equals("on");
|
||||
var account = player.getUniqueId();
|
||||
preferences.set(account, enabled).whenComplete((saved, failure) -> {
|
||||
var action = observe(player, enabled);
|
||||
var save = action == null ? preferences.set(account, enabled)
|
||||
: preferences.set(account, enabled, events.intent(action));
|
||||
save.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(failure);
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (closed) {
|
||||
if (action != null) events.failed(action, "closed");
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
if (action != null) events.failed(action, "save_failed");
|
||||
if (player.isOnline()) player.sendMessage(
|
||||
"Fantasy feedback preference could not be saved; it remains "
|
||||
+ state(preferences.enabled(account)) + ".");
|
||||
return;
|
||||
}
|
||||
boolean published = preferences.publish(saved);
|
||||
if (action != null) events.saved(action, saved, published, preferences.enabled(account));
|
||||
if (player.isOnline()) player.sendMessage("Fantasy feedback is "
|
||||
+ (published ? "now " : "already ") + state(preferences.enabled(account)) + ".");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(rejected); }
|
||||
} catch (RuntimeException rejected) {
|
||||
if (action != null) events.failed(action, "schedule_failed");
|
||||
report(rejected);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -62,6 +86,13 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
|
||||
return List.of("off", "on").stream().filter(value -> value.startsWith(prefix)).toList();
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge.Action observe(Player player, Boolean requested) {
|
||||
var bridge = events;
|
||||
if (bridge == null) return null;
|
||||
try { return bridge.begin(player, "combat_feedback", preferences.enabled(player.getUniqueId()), requested); }
|
||||
catch (RuntimeException ignored) { return null; } // Auditing cannot refuse a player preference.
|
||||
}
|
||||
|
||||
private static String state(boolean enabled) { return enabled ? "on" : "off"; }
|
||||
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
|
||||
@Override public void close() { closed = true; }
|
||||
|
||||
@@ -16,8 +16,13 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
|
||||
private final PlayerFeedbackPreferences preferences;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Throwable> failures;
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge events;
|
||||
private boolean closed;
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.PreferenceEventBridge events) {
|
||||
this.events = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
public FantasyParticlesCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
|
||||
Consumer<Throwable> failures) {
|
||||
this.preferences = Objects.requireNonNull(preferences);
|
||||
@@ -27,35 +32,60 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
if (args.length > 0 && (args[0].equals("all") || args[0].equals("players")) && events != null) {
|
||||
try { events.refuseNonPlayer(sender, key(args[0])); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
sender.sendMessage("Fantasy particles are a player-account preference.");
|
||||
return true;
|
||||
}
|
||||
if (args.length == 1 && (args[0].equals("players") || args[0].equals("all"))) {
|
||||
var action = observe(player, args[0], null);
|
||||
player.sendMessage(status(player.getUniqueId(), args[0]));
|
||||
if (action != null) events.read(action);
|
||||
return true;
|
||||
}
|
||||
if (args.length != 2 || !(args[0].equals("players") || args[0].equals("all"))
|
||||
|| !(args[1].equals("on") || args[1].equals("off"))) return false;
|
||||
|| !(args[1].equals("on") || args[1].equals("off"))) {
|
||||
if (args.length > 0 && (args[0].equals("all") || args[0].equals("players"))) {
|
||||
var action = observe(player, args[0], null);
|
||||
if (action != null) events.refused(action, "invalid_arguments");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
String route = args[0];
|
||||
boolean enabled = args[1].equals("on");
|
||||
UUID account = player.getUniqueId();
|
||||
var action = observe(player, route, enabled);
|
||||
var save = route.equals("players")
|
||||
? preferences.setPlayerAuras(account, enabled) : preferences.setAllParticles(account, enabled);
|
||||
? action == null ? preferences.setPlayerAuras(account, enabled)
|
||||
: preferences.setPlayerAuras(account, enabled, events.intent(action))
|
||||
: action == null ? preferences.setAllParticles(account, enabled)
|
||||
: preferences.setAllParticles(account, enabled, events.intent(action));
|
||||
save.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(failure);
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (closed) {
|
||||
if (action != null) events.failed(action, "closed");
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
if (action != null) events.failed(action, "save_failed");
|
||||
if (player.isOnline()) player.sendMessage(
|
||||
(route.equals("players") ? "Active-character aura" : "All-particles")
|
||||
+ " preference could not be saved; " + status(account, route));
|
||||
return;
|
||||
}
|
||||
boolean published = preferences.publish(saved);
|
||||
if (action != null) events.saved(action, saved, published,
|
||||
route.equals("players") ? preferences.auraParticlesEnabled(account)
|
||||
: preferences.allParticlesEnabled(account));
|
||||
if (player.isOnline()) player.sendMessage(acknowledgement(account, route, published));
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(rejected); }
|
||||
} catch (RuntimeException rejected) {
|
||||
if (action != null) events.failed(action, "schedule_failed");
|
||||
report(rejected);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -68,6 +98,18 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge.Action observe(
|
||||
Player player, String route, Boolean requested) {
|
||||
var bridge = events;
|
||||
if (bridge == null) return null;
|
||||
try { return bridge.begin(player, key(route), route.equals("players")
|
||||
? preferences.auraParticlesEnabled(player.getUniqueId())
|
||||
: preferences.allParticlesEnabled(player.getUniqueId()), requested); }
|
||||
catch (RuntimeException ignored) { return null; } // Capture failure cannot refuse gameplay.
|
||||
}
|
||||
|
||||
private static String key(String route) { return route.equals("players") ? "player-aura-particles" : "all-particles"; }
|
||||
|
||||
private String status(UUID account, String route) {
|
||||
if (route.equals("all"))
|
||||
return "All Fantasy Stick particles are " + state(preferences.allParticlesEnabled(account)) + ".";
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -66,6 +66,8 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
|
||||
private final CombatFeedback feedback;
|
||||
private final CombatSources sources;
|
||||
private java.util.function.Function<CharacterRecord, BigInteger> bonusHealing = ignored -> BigInteger.ZERO;
|
||||
private java.util.function.Function<CharacterRecord, BigInteger> quickerRegenerationContribution = ignored -> BigInteger.ZERO;
|
||||
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
|
||||
private final Map<UUID, State> states = new HashMap<>();
|
||||
private final ArrayDeque<UUID> order = new ArrayDeque<>();
|
||||
private final Set<UUID> queued = new HashSet<>();
|
||||
@@ -96,6 +98,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
|
||||
if (closed || task != null || !states.isEmpty()) throw new IllegalStateException("Recovery evaluation already started");
|
||||
bonusHealing = Objects.requireNonNull(bonus);
|
||||
}
|
||||
public synchronized void attachEvents(games.dmg.fantasystick.events.RecoveryEventBridge events,java.util.function.Function<CharacterRecord,BigInteger> quickerContribution){if(closed||recoveryEvents!=null)throw new IllegalStateException("Recovery events already attached or recovery closed");recoveryEvents=Objects.requireNonNull(events);quickerRegenerationContribution=Objects.requireNonNull(quickerContribution);}
|
||||
|
||||
public synchronized void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin);
|
||||
@@ -177,18 +180,19 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
|
||||
state.lastAttempt = now; // Full-health checks are not banked for later replay.
|
||||
return;
|
||||
}
|
||||
BigInteger exactAmount;
|
||||
try { exactAmount = configured.amount().add(Objects.requireNonNull(bonusHealing.apply(record))); }
|
||||
BigInteger exactAmount;BigInteger quickerContribution;
|
||||
try { exactAmount = configured.amount().add(Objects.requireNonNull(bonusHealing.apply(record)));quickerContribution=Objects.requireNonNull(quickerRegenerationContribution.apply(record)); }
|
||||
catch (RuntimeException failure) { state.lastAttempt = now; report(record.id(), failure); return; }
|
||||
if (exactAmount.signum() < 0) { state.lastAttempt = now; report(record.id(), new IllegalStateException("Negative recovery bonus")); return; }
|
||||
double amount = exactAmount.bitLength() > 1023 ? maximum - current : exactAmount.doubleValue();
|
||||
double proposed = Math.min(maximum - current, amount);
|
||||
if (!Double.isFinite(proposed) || proposed <= 0) return;
|
||||
var audit=recoveryEvents==null?null:recoveryEvents.begin(player,state.active,record,current,proposed,configured.amount(),quickerContribution);
|
||||
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event);
|
||||
try{healingEvents.accept(event);}catch(RuntimeException failure){if(audit!=null)recoveryEvents.failed(audit,"native_healing_event_failed");throw failure;}
|
||||
double accepted = Math.min(proposed, Math.min(maximum - current, event.getAmount()));
|
||||
state.lastAttempt = now;
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {if(audit!=null)recoveryEvents.refused(audit,event.isCancelled()?"native_event_cancelled":"no_positive_healing_accepted");return;}
|
||||
Object token = new Object();
|
||||
state.pending = token;
|
||||
long generation = state.generation;
|
||||
@@ -196,44 +200,45 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
|
||||
BigDecimal target = BigDecimal.valueOf(current + accepted);
|
||||
BigDecimal limit;
|
||||
try { limit = nativeLimit.get(); }
|
||||
catch (RuntimeException failure) { state.pending = null; state.lastAttempt = clock.instant(); report(record.id(), failure); return; }
|
||||
catch (RuntimeException failure) { state.pending = null; state.lastAttempt = clock.instant();if(audit!=null)recoveryEvents.failed(audit,"native_health_limit_unavailable"); report(record.id(), failure); return; }
|
||||
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> complete(state, token, generation, expected, current, saved, failure));
|
||||
nextTick.accept(() -> complete(state, token, generation, expected, current, configured.amount(), quickerContribution, audit, saved, failure));
|
||||
} catch (RuntimeException rejected) {
|
||||
synchronized (OutOfCombatRecovery.this) {
|
||||
if (state.pending == token) { state.pending = null; state.lastAttempt = clock.instant(); }
|
||||
}
|
||||
report(record.id(), rejected);
|
||||
if(audit!=null)recoveryEvents.failed(audit,"scheduler_rejected_after_save_uncertain");report(record.id(), rejected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private synchronized void complete(State state, Object token, long generation, BigInteger expected,
|
||||
double nativeBefore, java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
double nativeBefore, BigInteger configuredBase,BigInteger quickerConfigured,games.dmg.fantasystick.events.RecoveryEventBridge.Action audit,java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
if (state.pending != token) return;
|
||||
state.pending = null;
|
||||
if (failure != null || saved == null || saved.isEmpty()) {
|
||||
state.lastAttempt = clock.instant();
|
||||
report(state.active.character(), failure != null ? failure : new IllegalStateException("Character unavailable for recovery save"));
|
||||
if(audit!=null)recoveryEvents.failed(audit,failure!=null?"health_save_failed":"character_unavailable");report(state.active.character(), failure != null ? failure : new IllegalStateException("Character unavailable for recovery save"));
|
||||
return;
|
||||
}
|
||||
var persisted = saved.orElseThrow();
|
||||
boolean acknowledged = sessions.acknowledgeProgress(state.active, expected, persisted);
|
||||
if (!acknowledged) {
|
||||
report(state.active.character(), new IllegalStateException("Saved recovery could not synchronize with its active session"));
|
||||
if(audit!=null)recoveryEvents.failed(audit,"saved_but_session_publication_refused");report(state.active.character(), new IllegalStateException("Saved recovery could not synchronize with its active session"));
|
||||
return;
|
||||
}
|
||||
if (closed || generation != state.generation || states.get(state.active.player()) != state
|
||||
|| !sessions.current(state.active)) return;
|
||||
|| !sessions.current(state.active)) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_session_changed_before_native_heal");return;}
|
||||
var player = sessions.currentPlayer(state.active).orElse(null);
|
||||
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) return;
|
||||
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_native_health_changed");return;}
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) return;
|
||||
if (attribute == null) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_maximum_health_unavailable");return;}
|
||||
double maximum = Math.min(attribute.getValue(), persisted.maximumHealth().doubleValue());
|
||||
double publish = Math.min(maximum, persisted.health().doubleValue());
|
||||
if (!Double.isFinite(maximum) || !Double.isFinite(publish) || publish <= nativeBefore) return;
|
||||
player.setHealth(publish);
|
||||
if (!Double.isFinite(maximum) || !Double.isFinite(publish) || publish <= nativeBefore) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_no_native_heal_available");return;}
|
||||
try{player.setHealth(publish);}catch(RuntimeException nativeFailure){if(audit!=null)recoveryEvents.failed(audit,"native_health_publication_failed");throw nativeFailure;}
|
||||
if(audit!=null){var total=BigDecimal.valueOf(publish-nativeBefore);var base=total.min(new BigDecimal(configuredBase));var passive=total.subtract(base).min(new BigDecimal(quickerConfigured)).max(BigDecimal.ZERO);recoveryEvents.healed(audit,BigDecimal.valueOf(publish),total,base,passive);}
|
||||
if (feedback != null) feedback.report("Out-of-Combat Recovery", player, AbilityReport.Type.HEALING, player,
|
||||
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -77,6 +77,11 @@ class AbilityEngineNativeTest {
|
||||
(CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"), sessions, parties,
|
||||
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC),
|
||||
f.tasks::add, scheduler, reports::add, failure -> fail(failure.toString()));
|
||||
var captured = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
|
||||
engine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(
|
||||
event -> { if (event.type().equals("action.observed")) throw new IllegalStateException("test writer unavailable");
|
||||
captured.add(event); }, java.time.Clock.systemUTC()));
|
||||
engine.enableCastEventsFor("area_test"); engine.enableCastEventsFor("selection_test");
|
||||
var active = sessions.active(a.core.id).orElseThrow();
|
||||
var hostile = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
|
||||
org.mockito.Mockito.when(hostile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
@@ -85,6 +90,7 @@ class AbilityEngineNativeTest {
|
||||
reports.stream().map(games.dmg.fantasystick.ability.AbilityReport::type).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(5, reports.size());
|
||||
assertEquals(a.core.record.id(), reports.getFirst().sourceCharacter());
|
||||
assertTrue(captured.isEmpty(), "Unselected real abilities and reports cannot claim event delivery");
|
||||
|
||||
var ordinary = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
|
||||
org.mockito.Mockito.when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
@@ -109,11 +115,18 @@ class AbilityEngineNativeTest {
|
||||
assertEquals(0, delayedEffects.get(), "Party eligibility is rechecked at delayed application");
|
||||
|
||||
active = sessions.active(a.core.id).orElseThrow();
|
||||
cast(engine, f, a, active, 5, "selection_test", b.player);
|
||||
cast(engine, f, a, active, 5, "selection_test", null);
|
||||
assertEquals(1, delayed.size());
|
||||
var selectionCast = captured.stream().filter(event -> event.type().equals("ability.cast")
|
||||
&& event.data().get("ability_id").equals("selection_test")).findFirst().orElseThrow();
|
||||
assertEquals(active.session(), selectionCast.context().sessionId());
|
||||
engine.remove(a.core.record.id(), "selection_test");
|
||||
delayed.removeFirst().run();
|
||||
assertEquals(0, selectionEffects.get(), "Removal invalidates pending selection callbacks without clearing cooldowns");
|
||||
assertEquals(1, captured.stream().filter(event -> event.type().equals("ability.resolved")
|
||||
&& event.actionId().equals(selectionCast.actionId()) && event.data().get("outcome").equals("interrupted")).count());
|
||||
assertTrue(captured.stream().noneMatch(event -> event.type().equals("effect.applied")),
|
||||
"No-op casts and unused reports cannot invent an effect");
|
||||
var stored = ((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
|
||||
.find(a.core.record.id()).get().orElseThrow();
|
||||
assertTrue(stored.cooldowns().containsKey("selection_test"));
|
||||
@@ -138,7 +151,14 @@ class AbilityEngineNativeTest {
|
||||
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE, AbilityDefinition.Category.BUF,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
|
||||
Map.of("range", BigDecimal.valueOf(12)), AbilityDefinition.TargetPolicy.SELF,
|
||||
execution -> { assertTrue(execution.stillCurrent()); effects.incrementAndGet(); }));
|
||||
execution -> {
|
||||
assertTrue(execution.stillCurrent());
|
||||
effects.incrementAndGet(); // Test-defined native execution, not a real spell.
|
||||
execution.effectApplied("test_focus_effect", null, Map.of("native_applications", 1));
|
||||
}));
|
||||
var engine = (games.dmg.fantasystick.ability.AbilityEngine)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine");
|
||||
engine.enableCastEventsFor("test_focus");
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0);
|
||||
|
||||
@@ -201,6 +221,82 @@ class AbilityEngineNativeTest {
|
||||
assertEquals(1, effects.get(), "Exit/resume cannot reset the persisted character cooldown");
|
||||
assertEquals(cooled.cooldowns(), storage.find(a.core.record.id()).get().orElseThrow().cooldowns());
|
||||
}
|
||||
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());
|
||||
}
|
||||
for (var suffix : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved",
|
||||
"effect.applied", "effect.ended")) {
|
||||
assertEquals(1, events.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).count(), suffix);
|
||||
}
|
||||
var castEvent = events.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).findFirst().orElseThrow();
|
||||
assertEquals("test_focus", castEvent.getAsJsonObject("data").get("ability_id").getAsString());
|
||||
assertTrue(castEvent.getAsJsonObject("data").has("cooldown_until"));
|
||||
var applied = events.stream().filter(event -> event.get("type").getAsString().endsWith("effect.applied")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).findFirst().orElseThrow();
|
||||
var ended = events.stream().filter(event -> event.get("type").getAsString().endsWith("effect.ended")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).findFirst().orElseThrow();
|
||||
assertEquals(applied.getAsJsonObject("data").get("application_id").getAsString(),
|
||||
ended.getAsJsonObject("data").get("application_id").getAsString());
|
||||
assertEquals("1", applied.getAsJsonObject("data").get("native_applications").getAsString());
|
||||
assertTrue(events.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
|
||||
.anyMatch(event -> event.getAsJsonObject("data").get("action_id").getAsString()
|
||||
.equals(castEvent.getAsJsonObject("data").get("action_id").getAsString())));
|
||||
}
|
||||
|
||||
@Test void failedCooldownSaveOfRegisteredDefinitionNeverClaimsACastOrEffect() {
|
||||
var registry = new AbilityRegistry();
|
||||
var effects = new AtomicInteger();
|
||||
registry.register(new AbilityDefinition("test_failure", "Test Failure", Material.AMETHYST_SHARD,
|
||||
List.of("A test-only ability"), "Use the control", Map.of(), BigInteger.ONE,
|
||||
AbilityDefinition.Category.BUF, AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE,
|
||||
Duration.ofSeconds(4), Map.of(), AbilityDefinition.TargetPolicy.SELF,
|
||||
execution -> effects.incrementAndGet()));
|
||||
var original = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var record = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), original.name(),
|
||||
original.revision(), original.stats(), original.level(), original.experience(), original.unspentPoints(),
|
||||
original.health(), java.util.Set.of("test_failure"), List.of("test_failure", "", "", "", "", "", ""),
|
||||
original.cooldowns(), original.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 = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
|
||||
var world = org.mockito.Mockito.mock(org.bukkit.World.class);
|
||||
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(active.player());
|
||||
org.mockito.Mockito.when(player.getName()).thenReturn("Account");
|
||||
org.mockito.Mockito.when(world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
org.mockito.Mockito.when(world.getName()).thenReturn("world");
|
||||
org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world, 1, 64, 2));
|
||||
var sessions = org.mockito.Mockito.mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
org.mockito.Mockito.when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
|
||||
var storage = org.mockito.Mockito.mock(CharacterStorage.class);
|
||||
var failed = java.util.concurrent.CompletableFuture
|
||||
.<CharacterStorage.AbilityMutation>failedFuture(new java.io.IOException("simulated save failure"));
|
||||
org.mockito.Mockito.when(storage.commitAbilityCooldown(org.mockito.ArgumentMatchers.eq(record.id()),
|
||||
org.mockito.ArgumentMatchers.eq(record.revision()), org.mockito.ArgumentMatchers.eq("test_failure"),
|
||||
org.mockito.ArgumentMatchers.any())).thenReturn(failed);
|
||||
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
|
||||
var events = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
|
||||
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
|
||||
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
|
||||
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
|
||||
engine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(events::add,
|
||||
java.time.Clock.systemUTC()));
|
||||
engine.enableCastEventsFor("test_failure");
|
||||
engine.accept(new ControlInput(player, active, 2, "test_failure", ControlInput.Kind.ABILITY,
|
||||
ControlInput.Gesture.USE, null, 0, 0, () -> true));
|
||||
assertEquals(List.of("action.observed", "ability.resolved"), events.stream().map(
|
||||
games.dmg.fantasystick.events.EventInput::type).toList());
|
||||
assertEquals("failed", events.getLast().data().get("outcome"));
|
||||
assertEquals(events.getFirst().actionId(), events.getLast().actionId());
|
||||
assertEquals(0, effects.get());
|
||||
assertEquals(1, failures.size());
|
||||
engine.close(); parties.close();
|
||||
}
|
||||
|
||||
private static AbilityDefinition definition(String id, AbilityDefinition.Activation activation,
|
||||
|
||||
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user