Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a79814118e | ||
|
|
5923fcaf75 | ||
|
|
ec46e667f1 | ||
|
|
04101d4565 | ||
|
|
71839364a2 | ||
|
|
9425103745 | ||
|
|
8f2501f1c0 | ||
|
|
2f7a72355d | ||
|
|
01fd2f10ff | ||
|
|
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,54 @@ 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.FishingEventBridge fishingAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> fishingAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
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 java.util.concurrent.CompletionStage<Void> recoveryAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
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.RoleplayAvailabilityEventBridge roleplayAvailabilityEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> roleplayAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.PartyCapacityEventBridge partyCapacityEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> partyCapacityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.SheetResourcePackEventBridge sheetResourcePackEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> sheetResourcePackAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.AdminExperienceEventBridge adminExperienceEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> adminExperienceAuditDrain = 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 +277,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 +429,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 +466,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 +487,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 +513,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 +523,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 +555,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 +573,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 +646,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);
|
||||
@@ -630,7 +677,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
adventureQuestController.registerDungeonRoomPresence((dungeon,location)->location.getWorld()!=null&&dungeons.find(dungeon).filter(value->value.world().equals(location.getWorld().getUID())).isPresent()&&dungeons.roomAt(dungeon,location.getBlockX(),location.getBlockY(),location.getBlockZ()).isPresent());
|
||||
combatExperience.attachManagedOrigins(entity->adventureQuestController.encounterOrigin(entity).or(()->dungeonController.encounterOrigin(entity)));
|
||||
combatExperience.attachManagedSettlement(dungeonController::settleKill);
|
||||
combatExperience.attachExcluded(adventureQuestController::managed);
|
||||
combatExperience.attachExcluded(entity->adventureQuestController.encounterOrigin(entity).isPresent());
|
||||
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|
||||
|| armorerController.tagged(entity) || rareMerchantController.tagged(entity) || adventureQuestController.giver(entity));
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
@@ -641,6 +688,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);
|
||||
@@ -651,10 +699,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
admin.attachWarBanner(warBannerSettings, warBanner);
|
||||
admin.attachMeteor(meteorSettings, meteor);
|
||||
admin.attachAdminRevival(adminRevival, adminReviveSettings);
|
||||
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
|
||||
var grantService = new games.dmg.fantasystick.session.AdminExperienceGrant(
|
||||
characters, rules, sessions, nextTick, getServer()::getPlayer,
|
||||
problem -> getLogger().log(Level.WARNING,
|
||||
"Admin experience grant for character " + problem.character(), problem.cause()), particleDelivery));
|
||||
"Admin experience grant for character " + problem.character(), problem.cause()), particleDelivery);
|
||||
admin.attachExperienceGrant(grantService);
|
||||
admin.attachSwitchDelay(switchDelaySettings);
|
||||
admin.attachRoleplaySettings(roleplaySettings);
|
||||
admin.attachPartySettings(partySettings);
|
||||
@@ -702,16 +751,137 @@ 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());
|
||||
fishingAuditEvents = new games.dmg.fantasystick.events.FishingEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Fishing event intent failed", failure));
|
||||
adventureQuestCommands.attachFishingEvents(fishingAuditEvents);
|
||||
adventureQuestController.attachFishingEvents(fishingAuditEvents);
|
||||
fishingController.attachFishingEvents(fishingAuditEvents);
|
||||
admin.attachFishingEvents(fishingAuditEvents);
|
||||
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(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Recovery event intent failed", failure));
|
||||
outOfCombatRecovery.attachEvents(recoveryEvents,
|
||||
record -> record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)
|
||||
? quickerRegeneration.bonus() : java.math.BigInteger.ZERO,
|
||||
record -> record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)
|
||||
&& record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationII.ID)
|
||||
? quickerRegenerationII.bonus() : java.math.BigInteger.ZERO);
|
||||
admin.attachRecoveryEvents(recoveryEvents);
|
||||
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);
|
||||
roleplayAvailabilityEvents = new games.dmg.fantasystick.events.RoleplayAvailabilityEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Roleplay availability event intent failed", failure));
|
||||
admin.attachRoleplayAvailabilityEvents(roleplayAvailabilityEvents);
|
||||
partyCapacityEvents = new games.dmg.fantasystick.events.PartyCapacityEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Party capacity event intent failed", failure));
|
||||
admin.attachPartyCapacityEvents(partyCapacityEvents);
|
||||
sheetResourcePackEvents = new games.dmg.fantasystick.events.SheetResourcePackEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Sheet resource-pack event intent failed", failure));
|
||||
admin.attachSheetResourcePackEvents(sheetResourcePackEvents);
|
||||
sheetResourcePack.attachEvents(sheetResourcePackEvents);
|
||||
adminExperienceEvents = new games.dmg.fantasystick.events.AdminExperienceEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Admin experience event intent failed", failure));
|
||||
grantService.attachEvents(adminExperienceEvents);
|
||||
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; roleplayAvailabilityEvents = null; partyCapacityEvents = null; sheetResourcePackEvents = null; adminExperienceEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; fishingAuditEvents = null; messageAuditEvents = null;
|
||||
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
|
||||
}
|
||||
getServer().getPluginManager().registerEvents(crafting, this);
|
||||
@@ -786,12 +956,93 @@ 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 replayRoleplayEvents = roleplayAvailabilityEvents;
|
||||
roleplayAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous roleplay event intent drain failed", failure);
|
||||
if (replayRoleplayEvents != null) replayRoleplayEvents.replay();
|
||||
});
|
||||
var replayPartyCapacityEvents = partyCapacityEvents;
|
||||
partyCapacityAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous party capacity event intent drain failed", failure);
|
||||
if (replayPartyCapacityEvents != null) replayPartyCapacityEvents.replay();
|
||||
});
|
||||
var replaySheetPackEvents = sheetResourcePackEvents;
|
||||
sheetResourcePackAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous sheet pack event intent drain failed", failure);
|
||||
if (replaySheetPackEvents != null) replaySheetPackEvents.replay();
|
||||
});
|
||||
var replayGrantEvents = adminExperienceEvents;
|
||||
adminExperienceAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous admin experience intent drain failed", failure);
|
||||
if (replayGrantEvents != null) replayGrantEvents.replay();
|
||||
});
|
||||
var replayRecoveryEvents = recoveryEvents;
|
||||
recoveryAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous recovery event intent drain failed", failure);
|
||||
if (replayRecoveryEvents != null) replayRecoveryEvents.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();
|
||||
});
|
||||
var replayFishingEvents = fishingAuditEvents;
|
||||
fishingAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous fishing event intent drain failed", failure);
|
||||
if (replayFishingEvents != null) replayFishingEvents.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 +1064,21 @@ 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 closingFishingAuditEvents = fishingAuditEvents; fishingAuditEvents = null;
|
||||
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
|
||||
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
|
||||
var closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
|
||||
var closingRoleplayEvents = roleplayAvailabilityEvents; roleplayAvailabilityEvents = null;
|
||||
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
|
||||
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
|
||||
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
|
||||
var closingRecoveryEvents = recoveryEvents; recoveryEvents = 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; 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 +1119,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 +1194,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 +1281,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 +1385,126 @@ 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 (closingRoleplayEvents != null) cleanup("roleplay availability event intent drain", () -> {
|
||||
var settingsDrained = closingRoleplaySettings == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
|
||||
: closingRoleplaySettings.drained();
|
||||
roleplayAuditDrain = settingsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Roleplay setting drain failed", failure);
|
||||
closingRoleplayEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingRoleplayEvents.drained());
|
||||
});
|
||||
if (closingPartyCapacityEvents != null) cleanup("party capacity event intent drain", () -> {
|
||||
var settingsDrained = closingPartySettings == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
|
||||
: closingPartySettings.drained();
|
||||
partyCapacityAuditDrain = settingsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Party setting drain failed", failure);
|
||||
closingPartyCapacityEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingPartyCapacityEvents.drained());
|
||||
});
|
||||
if (closingSheetPackEvents != null) cleanup("sheet resource-pack event intent drain", () -> {
|
||||
var settingsDrained = closingSheetResourcePackSettings == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
|
||||
: closingSheetResourcePackSettings.drained();
|
||||
sheetResourcePackAuditDrain = settingsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Sheet pack setting drain failed", failure);
|
||||
closingSheetPackEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingSheetPackEvents.drained());
|
||||
});
|
||||
if (closingGrantEvents != null) cleanup("admin experience event intent drain", () -> {
|
||||
var storageDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
|
||||
: closingCharacters.drained();
|
||||
adminExperienceAuditDrain = storageDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character storage drain failed", failure);
|
||||
closingGrantEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingGrantEvents.drained());
|
||||
});
|
||||
if (closingRecoveryEvents != null) cleanup("recovery event intent drain", () -> {
|
||||
var settingsDrained = closingOutOfCombatRecoverySettings == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
|
||||
: closingOutOfCombatRecoverySettings.drained();
|
||||
recoveryAuditDrain = settingsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Recovery setting drain failed", failure);
|
||||
closingRecoveryEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingRecoveryEvents.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 (closingFishingAuditEvents != null) cleanup("fishing event intent drain", () -> {
|
||||
var characterDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
var fishingDrained = closingFishing == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingFishing.drained();
|
||||
var adventureDrained = closingAdventureQuests == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingAdventureQuests.drained();
|
||||
fishingAuditDrain = java.util.concurrent.CompletableFuture.allOf(characterDrained.toCompletableFuture(),
|
||||
fishingDrained.toCompletableFuture(), adventureDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
|
||||
.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Fishing owner drain failed", failure);
|
||||
closingFishingAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingFishingAuditEvents.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 +1544,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 +1793,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;
|
||||
|
||||
@@ -12,12 +12,14 @@ public final class AdventureQuestCommands {
|
||||
private static final String PERMISSION="fantasystick.admin";
|
||||
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestController controller;private final Consumer<Runnable> nextTick;private final AdventureLocationPresentation locations;private final AdventureAdminTeleport teleports;
|
||||
public AdventureQuestCommands(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestController controller,Consumer<Runnable> nextTick){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.nextTick=Objects.requireNonNull(nextTick);locations=new AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
|
||||
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
|
||||
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
|
||||
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fstick adventure");return;}if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fstickadmin adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fstick adventure [tracking [on|off]] or /fstickadmin adventure reroll [confirm]");return;}if(!authorized(sender))return;var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();directory.rerollToday(expected).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reset failed; the current generation is unchanged.");return;}switch(result.status()){case STALE->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again.");case APPLIED->{var catalog=result.catalog().orElseThrow();controller.publishPendingReset().whenComplete((published,publishFailure)->respond(sender,()->{if(publishFailure!=null||!published){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}));}}}));}
|
||||
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()+"; mode="+v.participationMode().value()+siteDetails(v)));}case"place"->{if(args.length<3||!(sender instanceof Player player)){usageSite(sender);return;}if(args[2].equals("fishing")){placeFishing(sender,player,args);return;}if(!(args[2].equals("camp")||args[2].equals("champion"))){usageSite(sender);return;}int start=3;var mode=AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args.length>3&&args[3].equals("--mode")){if(args.length<5){usageSite(sender);return;}try{mode=AdventureQuestState.ParticipationMode.parse(args[4]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}start=5;}var placement=placement(args,start);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena(),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+"; mode="+value.participationMode().value()+".":"Adventure site save failed; desired state is unchanged.")));}case"fishing"->{fishing(sender,args);}case"mode"->{if(args.length<3||args.length>4){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:site(id).orElse(null);if(current==null){sender.sendMessage("Adventure site not found.");return;}if(args.length==3){sender.sendMessage("Adventure site "+id+" mode="+current.participationMode().value()+".");return;}AdventureQuestState.ParticipationMode requested;try{requested=AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure site mode save failed; the acknowledged mode is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated adventure site "+id+" mode="+result.site().orElseThrow().participationMode().value()+" for future acceptances.";case UNCHANGED->"Adventure site "+id+" mode is already "+requested.value()+".";case STALE->"Adventure site mode update refused: the acknowledged mode changed; inspect and retry.";case NOT_FOUND->"Adventure site not found.";});}));}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}case"teleport"->{if(args.length!=3){usageSite(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Adventure-site teleport requires a player.");return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var site=site(id).orElse(null);if(site==null){sender.sendMessage("Adventure site not found.");return;}teleports.teleport(sender,"Adventure-site",target(site),()->site(id).map(AdventureQuestCommands::target));}default->usageSite(sender);}}
|
||||
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()+"; mode="+v.participationMode().value()+siteDetails(v)));}case"place"->{if(args.length<3||!(sender instanceof Player player)){usageSite(sender);return;}if(args[2].equals("fishing")){placeFishing(sender,player,args);return;}if(!(args[2].equals("camp")||args[2].equals("champion"))){usageSite(sender);return;}int start=3;var mode=AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args.length>3&&args[3].equals("--mode")){if(args.length<5){usageSite(sender);return;}try{mode=AdventureQuestState.ParticipationMode.parse(args[4]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}start=5;}var placement=placement(args,start);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena(),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+"; mode="+value.participationMode().value()+".":"Adventure site save failed; desired state is unchanged.")));}case"fishing"->{fishing(sender,args);}case"mode"->{if(args.length<3||args.length>4){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:site(id).orElse(null);if(current==null){sender.sendMessage("Adventure site not found.");return;}if(args.length==3){sender.sendMessage("Adventure site "+id+" mode="+current.participationMode().value()+".");return;}AdventureQuestState.ParticipationMode requested;try{requested=AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}var audit=fishingEvents;var auditActor=audit==null?null:audit.actor(sender);directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->{if(audit!=null&¤t.type()==AdventureQuestState.SiteType.FISHING&&failure==null&&result.status()==AdventureQuestDirectory.ModeStatus.APPLIED)audit.site("adventure.site.mode_changed",UUID.randomUUID(),auditActor,result.site().orElseThrow(),Map.of("mode_before",current.participationMode().value(),"mode_after",requested.value()));respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure site mode save failed; the acknowledged mode is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated adventure site "+id+" mode="+result.site().orElseThrow().participationMode().value()+" for future acceptances.";case UNCHANGED->"Adventure site "+id+" mode is already "+requested.value()+".";case STALE->"Adventure site mode update refused: the acknowledged mode changed; inspect and retry.";case NOT_FOUND->"Adventure site not found.";});});});}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}case"teleport"->{if(args.length!=3){usageSite(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Adventure-site teleport requires a player.");return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var site=site(id).orElse(null);if(site==null){sender.sendMessage("Adventure site not found.");return;}teleports.teleport(sender,"Adventure-site",target(site),()->site(id).map(AdventureQuestCommands::target));}default->usageSite(sender);}}
|
||||
public void giver(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageGiver(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageGiver(sender);return;}if(directory.snapshot().givers().isEmpty())sender.sendMessage("No Quest Givers registered.");else directory.snapshot().givers().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())));}case"place"->{if(!(sender instanceof Player player)){usageGiver(sender);return;}String name=join(args,2);var location=player.getLocation();directory.placeGiver(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Quest Giver save failed; desired state is unchanged.");return;}var world=plugin.getServer().getWorld(value.world());if(world!=null&&world.isChunkLoaded(section(value.x()),section(value.z())))controller.reconcile(world.getChunkAt(section(value.x()),section(value.z())));sender.sendMessage("Placed Quest Giver "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+".");}));}case"remove"->{if(args.length!=3){usageGiver(sender);return;}UUID id=parse(args[2]);if(id==null){usageGiver(sender);return;}directory.removeGiver(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Quest Giver removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Quest Giver not found.");else{controller.removeGiverLoaded(id);sender.sendMessage("Removed Quest Giver "+id+".");}}));}default->usageGiver(sender);}}
|
||||
public List<String> complete(CommandSender sender,String[] args){if(args.length==1){var values=new ArrayList<String>();values.add("adventure");if(sender.hasPermission(PERMISSION)){values.add("adventure-site");values.add("quest-giver");}return matching(values,args[0]);}if(args.length==2&&args[0].equals("adventure")){var values=new ArrayList<String>();if(sender instanceof Player player&&controller.trackingAvailable(player))values.add("tracking");if(sender.hasPermission(PERMISSION))values.add("reroll");return matching(values,args[1]);}if(args.length==3&&args[0].equals("adventure")&&args[1].equals("tracking")&&sender instanceof Player player&&controller.trackingAvailable(player))return matching(List.of("on","off"),args[2]);if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("fishing","list","mode","place","remove","teleport"):List.of("fishing","list","mode","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion","fishing"),args[2]);if(args.length>=4&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[2].equals("fishing"))return fishingOptionCompletion(args);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("place")&&(args[2].equals("camp")||args[2].equals("champion"))){var values=new ArrayList<>(List.of("--mode","4","16","64"));return matching(values,args[3]);}if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(modeValues(),args[4]);if(args.length==6&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(List.of("4","16","64"),args[5]);if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&whole(args[3])!=null)return matching(List.of("8","16","128"),args[4]);if(args.length==7&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode")&&whole(args[5])!=null)return matching(List.of("8","16","128"),args[6]);if(args.length==3&&args[0].equals("adventure-site")&&Set.of("fishing","mode","remove","teleport").contains(args[1]))return matching(directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("mode"))return matching(modeValues(),args[3]);if(args.length>=4&&args[0].equals("adventure-site")&&args[1].equals("fishing"))return fishingOptionCompletion(args);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
|
||||
private void placeFishing(CommandSender sender,Player player,String[] args){var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||!options.keySet().containsAll(Set.of("name","item","required","chance","xp","coins"))){usageSite(sender);return;}try{int radius=options.containsKey("radius")?bounded(options.get("radius"),4,64):directory.defaultRadius();var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var location=player.getLocation();directory.placeFishingSite(options.get("name"),options.get("item"),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),radius,percent(options.get("chance")),positive(options.get("required")),nonnegative(options.get("xp")),nonnegative(options.get("coins")),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed fishing site "+value.id()+" ("+value.displayName()+"); "+fishingDetails(value)+".":"Fishing site save failed; desired state is unchanged.")));}catch(IllegalArgumentException invalid){usageSite(sender);}}
|
||||
private void fishing(CommandSender sender,String[] args){if(args.length<3){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:directory.findSite(id).filter(value->value.type()==AdventureQuestState.SiteType.FISHING).orElse(null);if(current==null){sender.sendMessage("Fishing site not found.");return;}if(args.length==3){sender.sendMessage(current.id()+" "+current.displayName()+"; "+fishingDetails(current)+".");return;}var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||options.isEmpty()){usageSite(sender);return;}try{var before=current.fishing();var definition=new AdventureQuestState.FishingDefinition(before.itemId(),options.getOrDefault("item",before.itemName()),options.containsKey("required")?positive(options.get("required")):before.required(),options.containsKey("chance")?percent(options.get("chance")):before.chancePercent(),options.containsKey("xp")?nonnegative(options.get("xp")):before.rewardXp(),options.containsKey("coins")?nonnegative(options.get("coins")):before.rewardCoins(),options.containsKey("radius")?bounded(options.get("radius"),4,64):before.radius());var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):current.participationMode();directory.updateFishingSite(id,current,options.getOrDefault("name",current.name()),definition,mode).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Fishing site update failed; the acknowledged definition is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated fishing site "+id+"; "+fishingDetails(result.site().orElseThrow())+".";case UNCHANGED->"Fishing site "+id+" is unchanged.";case STALE->"Fishing site update refused: the acknowledged definition changed; inspect and retry.";case NOT_FOUND->"Fishing site not found.";});}));}catch(IllegalArgumentException invalid){usageSite(sender);}}
|
||||
private void placeFishing(CommandSender sender,Player player,String[] args){var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||!options.keySet().containsAll(Set.of("name","item","required","chance","xp","coins"))){usageSite(sender);return;}try{int radius=options.containsKey("radius")?bounded(options.get("radius"),4,64):directory.defaultRadius();var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var location=player.getLocation();var audit=fishingEvents;var actor=audit==null?null:audit.actor(sender);directory.placeFishingSite(options.get("name"),options.get("item"),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),radius,percent(options.get("chance")),positive(options.get("required")),nonnegative(options.get("xp")),nonnegative(options.get("coins")),mode).whenComplete((value,failure)->{if(failure==null&&audit!=null)audit.site("adventure.site.registered",value.id(),actor,value,Map.of("definition","created"));respond(sender,()->sender.sendMessage(failure==null?"Placed fishing site "+value.id()+" ("+value.displayName()+"); "+fishingDetails(value)+".":"Fishing site save failed; desired state is unchanged."));});}catch(IllegalArgumentException invalid){usageSite(sender);}}
|
||||
private void fishing(CommandSender sender,String[] args){if(args.length<3){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:directory.findSite(id).filter(value->value.type()==AdventureQuestState.SiteType.FISHING).orElse(null);if(current==null){sender.sendMessage("Fishing site not found.");return;}if(args.length==3){sender.sendMessage(current.id()+" "+current.displayName()+"; "+fishingDetails(current)+".");return;}var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||options.isEmpty()){usageSite(sender);return;}try{var before=current.fishing();var definition=new AdventureQuestState.FishingDefinition(before.itemId(),options.getOrDefault("item",before.itemName()),options.containsKey("required")?positive(options.get("required")):before.required(),options.containsKey("chance")?percent(options.get("chance")):before.chancePercent(),options.containsKey("xp")?nonnegative(options.get("xp")):before.rewardXp(),options.containsKey("coins")?nonnegative(options.get("coins")):before.rewardCoins(),options.containsKey("radius")?bounded(options.get("radius"),4,64):before.radius());var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):current.participationMode();var audit=fishingEvents;var actor=audit==null?null:audit.actor(sender);directory.updateFishingSite(id,current,options.getOrDefault("name",current.name()),definition,mode).whenComplete((result,failure)->{if(failure==null&&result.status()==AdventureQuestDirectory.FishingUpdateStatus.APPLIED&&audit!=null){var changes=new LinkedHashMap<String,Object>();for(var field:options.keySet())changes.put(field,options.get(field));audit.site("adventure.site.updated",id,actor,result.site().orElseThrow(),changes);}respond(sender,()->{if(failure!=null){sender.sendMessage("Fishing site update failed; the acknowledged definition is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated fishing site "+id+"; "+fishingDetails(result.site().orElseThrow())+".";case UNCHANGED->"Fishing site "+id+" is unchanged.";case STALE->"Fishing site update refused: the acknowledged definition changed; inspect and retry.";case NOT_FOUND->"Fishing site not found.";});});});}catch(IllegalArgumentException invalid){usageSite(sender);}}
|
||||
private static String siteDetails(AdventureQuestState.Site site){return site.fishing()==null?"":"; "+fishingDetails(site);}
|
||||
private static String fishingDetails(AdventureQuestState.Site site){var value=site.fishing();return "item=\""+value.itemName()+"\", required="+value.required()+", chance="+value.chancePercent().toPlainString()+"%, reward="+value.rewardXp()+" XP/"+value.rewardCoins()+" coins, radius="+value.radius()+", mode="+site.participationMode().value();}
|
||||
private static Map<String,String> options(String[] args,int start,Set<String> allowed){var result=new LinkedHashMap<String,String>();for(int index=start;index<args.length;index++){String raw=args[index];if(!raw.startsWith("--")||!allowed.contains(raw.substring(2))||result.containsKey(raw.substring(2))||index+1>=args.length)return null;String key=raw.substring(2),value=args[++index];if(value.startsWith("\"")){var joined=new StringBuilder(value);while(!joined.toString().endsWith("\"")&&index+1<args.length)joined.append(' ').append(args[++index]);value=joined.toString();if(value.length()<2||!value.endsWith("\""))return null;value=value.substring(1,value.length()-1);}if(value.isBlank()||value.startsWith("--"))return null;result.put(key,value);}return result;}
|
||||
|
||||
@@ -38,37 +38,63 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private FishingController fishing;
|
||||
private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private final Map<UUID,Attempt> attempts=new HashMap<>();private final Map<UUID,Instant> readyAt=new HashMap<>();private final Set<UUID> starting=new HashSet<>();
|
||||
/** A bounded same-enable receipt for genuine deaths after their attempt has been cleaned up. */
|
||||
private final LinkedHashMap<UUID,games.dmg.fantasystick.progression.ManagedEncounterOrigin> dyingOrigins=new LinkedHashMap<>();
|
||||
private final List<AdventureResetParticipant> resetParticipants=new ArrayList<>();private CompletableFuture<Boolean> resetWork;
|
||||
private java.util.function.BiPredicate<UUID,Location> dungeonRoomPresence=(ignored,location)->false;
|
||||
private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
|
||||
private static final class MenuHolder implements InventoryHolder {final UUID catalog;final int page;MenuHolder(UUID catalog,int page){this.catalog=catalog;this.page=page;}@Override public Inventory getInventory(){return null;}}
|
||||
private static final class Attempt {final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final AdventureQuestState.Arena arena;final int resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;Attempt(UUID site,UUID catalog,AdventureQuestState.SiteType type,AdventureQuestState.Arena arena,int resetSeconds){this.site=site;this.catalog=catalog;this.type=type;this.arena=arena;this.resetSeconds=resetSeconds;}}
|
||||
private static final class MenuHolder implements InventoryHolder {final UUID catalog;final int page;final AdventureQuestState.Giver giver;MenuHolder(UUID catalog,int page,AdventureQuestState.Giver giver){this.catalog=catalog;this.page=page;this.giver=giver;}@Override public Inventory getInventory(){return null;}}
|
||||
private static final class Attempt {final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final AdventureQuestState.Arena arena;final int resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;
|
||||
UUID defeatedEntity;Map<String,Object> defeatLocation=Map.of("status","unavailable");Instant defeatedAt;
|
||||
Attempt(UUID site,UUID catalog,AdventureQuestState.SiteType type,AdventureQuestState.Arena arena,int resetSeconds){this.site=site;this.catalog=catalog;this.type=type;this.arena=arena;this.resetSeconds=resetSeconds;}}
|
||||
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random){this(plugin,directory,settings,sessions,parties,characters,progression,nextTick,clock,random,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
|
||||
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.settings=Objects.requireNonNull(settings);this.particles=Objects.requireNonNull(particles);navigation=new AdventureQuestNavigation(plugin.getServer());this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);typeKey=new NamespacedKey(plugin,"managed_npc_type");idKey=new NamespacedKey(plugin,"quest_giver_id");siteKey=new NamespacedKey(plugin,"adventure_site_id");catalogKey=new NamespacedKey(plugin,"adventure_catalog_id");attemptKey=new NamespacedKey(plugin,"adventure_attempt_id");encounterKindKey=new NamespacedKey(plugin,"adventure_encounter_kind");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");menuPageKey=new NamespacedKey(plugin,"quest_offer_page");menuFishingKey=new NamespacedKey(plugin,"quest_fishing_turn_in");}
|
||||
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}pulseTask=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20,20);publishPendingReset();}
|
||||
public boolean giver(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&GIVER.equals(data.get(typeKey,PersistentDataType.STRING));}
|
||||
public boolean enemy(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&ENEMY.equals(data.get(typeKey,PersistentDataType.STRING));}
|
||||
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();return enemy(entity)||data!=null&&PROJECTILE.equals(data.get(typeKey,PersistentDataType.STRING));}
|
||||
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){if(!enemy(entity))return Optional.empty();var data=entity.getPersistentDataContainer();UUID site=site(entity),catalog=parse(data.get(catalogKey,PersistentDataType.STRING)),source=parse(data.get(attemptKey,PersistentDataType.STRING));String encoded=data.get(encounterKindKey,PersistentDataType.STRING);if(site==null||catalog==null||source==null||encoded==null)return Optional.empty();games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind kind;try{kind=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.valueOf(encoded);}catch(IllegalArgumentException invalid){return Optional.empty();}if(kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION&&kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP)return Optional.empty();var active=attempts.get(site);if(active!=null&&(!active.catalog.equals(catalog)||!active.id.equals(source)))return Optional.empty();return Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,source));}
|
||||
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){
|
||||
if(!enemy(entity)||entity.getUniqueId()==null)return Optional.empty();
|
||||
var data=entity.getPersistentDataContainer();UUID site=site(entity);
|
||||
UUID catalog=parse(data.get(catalogKey,PersistentDataType.STRING));
|
||||
UUID source=parse(data.get(attemptKey,PersistentDataType.STRING));
|
||||
String encoded=data.get(encounterKindKey,PersistentDataType.STRING);
|
||||
if(site==null||catalog==null||source==null||encoded==null)return Optional.empty();
|
||||
games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind kind;
|
||||
try{kind=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.valueOf(encoded);}
|
||||
catch(IllegalArgumentException invalid){return Optional.empty();}
|
||||
if(kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION
|
||||
&&kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP)return Optional.empty();
|
||||
var origin=new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,source);
|
||||
var remembered=dyingOrigins.get(entity.getUniqueId());
|
||||
if(origin.equals(remembered))return Optional.of(origin);
|
||||
var active=attempts.get(site);
|
||||
return active!=null&&active.catalog.equals(catalog)&&active.id.equals(source)
|
||||
&&active.entities.contains(entity.getUniqueId())&&active.type.name().equals(kind.name())
|
||||
?Optional.of(origin):Optional.empty();
|
||||
}
|
||||
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,AdventureQuestState.Giver>();for(var giver:directory.snapshot().givers())if(giver.world().equals(chunk.getWorld().getUID())&§ion(giver.x())==chunk.getX()&§ion(giver.z())==chunk.getZ())desired.put(giver.id(),giver);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(managed(entity)){entity.remove();continue;}if(!giver(entity))continue;UUID id=id(entity,idKey);var value=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||value==null||!retained.add(id)){entity.remove();continue;}configure(villager,value);var expected=location(value);var current=villager.getLocation();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();spawnGiver(value);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawnGiver(value);}
|
||||
public void removeGiverLoaded(UUID id){for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)&&id.equals(id(entity,idKey)))entity.remove();}
|
||||
public void removeSiteLoaded(UUID id){var attempt=attempts.remove(id);if(attempt!=null)cleanup(attempt);readyAt.remove(id);refreshTrackingPlayers();}
|
||||
public boolean hasActiveEncounter(){return !attempts.isEmpty()||!starting.isEmpty();}
|
||||
public AdventureQuestState.Arena arena(AdventureQuestState.Site site){return site.arenaOrDefault(settings.rules().radius());}
|
||||
public void registerFishing(FishingController fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing already attached or adventure controller closed");this.fishing=Objects.requireNonNull(fishing);}
|
||||
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
|
||||
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
|
||||
public void registerResetParticipant(AdventureResetParticipant participant){if(closed)throw new IllegalStateException("Adventure controller closed");resetParticipants.add(Objects.requireNonNull(participant));}
|
||||
public void registerDungeonRoomPresence(java.util.function.BiPredicate<UUID,Location> presence){if(closed)throw new IllegalStateException("Adventure controller closed");dungeonRoomPresence=Objects.requireNonNull(presence);}
|
||||
public CompletionStage<Boolean> publishPendingReset(){var reset=directory.snapshot().reset();if(reset==null)return CompletableFuture.completedFuture(true);if(resetWork!=null)return resetWork;for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();starting.clear();readyAt.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();refreshTrackingPlayers();var work=resetParticipants.stream().map(participant->{try{return participant.reset(reset).toCompletableFuture();}catch(RuntimeException failure){return CompletableFuture.<Void>failedFuture(failure);}}).toArray(CompletableFuture[]::new);resetWork=CompletableFuture.allOf(work).thenCompose(ignored->directory.completeReset(reset.operation())).whenComplete((ignored,failure)->resetWork=null);return resetWork;}
|
||||
private void spawnGiver(AdventureQuestState.Giver value){var world=plugin.getServer().getWorld(value.world());if(world==null||!world.isChunkLoaded(section(value.x()),section(value.z())))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
|
||||
private void configure(Villager villager,AdventureQuestState.Giver value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,GIVER);data.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.displayName()));villager.setCustomNameVisible(true);}
|
||||
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 Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}attemptClaim(player,context.active,pending.orElseThrow(),record);return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.min(54,Math.max(18,((count+8)/9)*9+9));var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon";case FISHING->"Fishing";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Completion trophy pool: ":"Reward: ";var lore=new ArrayList<net.kyori.adventure.text.Component>();lore.add(net.kyori.adventure.text.Component.text(kind));var fishingTerms=active!=null&&active.site().equals(siteId)&&active.fishing()!=null?active.fishing():site==null||site.fishing()==null?null:site.fishing().terms();if(fishingTerms!=null){lore.add(net.kyori.adventure.text.Component.text("Item: "+fishingTerms.itemName()+" × "+fishingTerms.required()));lore.add(net.kyori.adventure.text.Component.text("Catch chance: "+fishingTerms.chancePercent().toPlainString()+"%"));if(active!=null&&active.site().equals(siteId)&&fishing!=null)lore.add(net.kyori.adventure.text.Component.text("Held: "+fishing.quantity(context.character,fishingTerms.itemId())+" / "+fishingTerms.required()));}lore.add(net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"));if(site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON)lore.add(net.kyori.adventure.text.Component.text("Mob kills: additional variable XP, coins and native loot"));if(site!=null){lore.add(net.kyori.adventure.text.Component.text("Mode: "+site.participationMode().value()));if(active==null&&!rewardsReady&&!directory.characterClaimed(catalog.revision(),siteId,context.character)&&directory.offerOpen(siteId)&&directory.available(site))acceptanceLore(lore,site.participationMode(),context);}lore.add(net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()));lore.add(net.kyori.adventure.text.Component.text(state));meta.lore(lore);meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)inventory.setItem(45,pageItem(page-1,"Previous page"));if(page+1<pages)inventory.setItem(53,pageItem(page+1,"Next page"));}if(fishing!=null){var turnIn=new ItemStack(Material.ENDER_CHEST);var turLine truncated
|
||||
private void open(Player player,int requestedPage){open(player,requestedPage,null);}
|
||||
private void open(Player player,int requestedPage,AdventureQuestState.Giver giver){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}attemptClaim(player,context.active,pending.orElseThrow(),record);return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.min(54,Math.max(18,((count+8)/9)*9+9));var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page,giver),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon";case FISHING->"Fishing";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Completion trophy pool: ":"Reward: ";var lore=new ArrayList<net.kyori.adventure.text.Component>();lore.add(net.kyori.adventure.text.Component.text(kind));var fishingTerms=active!=null&&active.site().equals(siteId)&&active.fishing()!=null?active.fishing():site==null||site.fishing()==null?null:site.fishing().terms();if(fishingTerms!=null){lore.add(net.kyori.adventure.text.Component.text("Item: "+fishingTerms.itemName()+" × "+fishingTerms.required()));lore.add(net.kyori.adventure.text.Component.text("Catch chance: "+fishingTerms.chancePercent().toPlainString()+"%"));if(active!=null&&active.site().equals(siteId)&&fishing!=null)lore.add(net.kyori.adventure.text.Component.text("Held: "+fishing.quantity(context.character,fishingTerms.itemId())+" / "+fishingTerms.required()));}lore.add(net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"));if(site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON)lore.add(net.kyori.adventure.text.Component.text("Mob kills: additional variable XP, coins and native loot"));if(site!=null){lore.add(net.kyori.adventure.text.Component.text("Mode: "+site.participationMode().value()));if(active==null&&!rewardsReady&&!directory.characterClaimed(catalog.revision(),siteId,context.character)&&directory.offerOpen(siteId)&&directory.available(site))acceptanceLore(lore,site.participationMode(),context);}lore.add(net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()));lore.add(net.kyori.adventure.text.Component.text(state));meta.lore(lore);meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)inventory.setItem(45,pageItem(page-1,"Previous page"));if(page+1<pages)inventory.setItem(53,pageItem(page+1,"Next page"));}if(fishing!=null){var turnIn=new ILine truncated
|
||||
private ItemStack pageItem(int page,String name){var item=new ItemStack(Material.ARROW);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text(name));meta.getPersistentDataContainer().set(menuPageKey,PersistentDataType.INTEGER,page);item.setItemMeta(meta);return item;}
|
||||
private static void acceptanceLore(List<net.kyori.adventure.text.Component> lore,AdventureQuestState.ParticipationMode mode,Context context){if(mode!=AdventureQuestState.ParticipationMode.PARTY_REQUIRED)lore.add(net.kyori.adventure.text.Component.text("Left click: Accept individually"));if(mode!=AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY){if(context.partyId!=null&&context.leader)lore.add(net.kyori.adventure.text.Component.text("Right click: Accept for party"));else lore.add(net.kyori.adventure.text.Component.text("Party acceptance requires the active leader"));}}
|
||||
private record Context(EquipmentSessions.Active active,UUID character,UUID partyId,boolean leader,Set<UUID> members){}
|
||||
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null)return new Context(active,record.id(),null,true,Set.of(record.id()));return new Context(active,record.id(),party.id(),party.leaderCharacter().equals(record.id()),Set.copyOf(party.characters()));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(PlayerInteractEntityEvent event){if(!giver(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked(),idKey);if(id!=null&&directory.findGiver(id).isPresent())open(event.getPlayer());}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){fishing.open(player);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);}));return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}var offered=directory.findSite(site).orElseThrow();var type=event.isRightClick()?AdventureQuestState.UnitType.PARTY:AdventureQuestState.UnitType.SOLO;if(!offered.participationMode().permits(type)){player.sendMessage("Quest acceptance refused: this site is "+offered.participationMode().value()+".");return;}if(type==AdventureQuestState.UnitType.PARTY&&(context.partyId==null||!context.leader)){player.sendMessage("Only the active party leader may accept a party quest.");return;}var unit=type==AdventureQuestState.UnitType.SOLO?context.character:context.partyId;var members=type==AdventureQuestState.UnitType.SOLO?Set.of(context.character):context.members;directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&¤t.active.equals(context.active)&¤t.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&¤t.members.equals(context.members));if(!same){directory.abandon(quest.id(),quest.unit()).whenComplete((ignored,rollbackFailure)->schedule(()->player.sendMessage(rollbackFailure==null?"Quest acceptance refused: the character or party changed.":"Quest acceptance is pending safe cancellation; retry after recovery.")));return;}var location=directory.findSite(site).orElseThrow();refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+quest.expiresAt()+". Follow the Quest Compass.");}));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(PlayerInteractEntityEvent event){if(!giver(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked(),idKey);if(id!=null)directory.findGiver(id).ifPresent(giver->open(event.getPlayer(),0,giver));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page,holder.giver);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){var now=directory.snapshot();if(now.reset()!=null||now.catalog()==null||!holder.catalog.equals(now.catalog().revision())){player.sendMessage("Quest item entry refused: the Quest Giver menu expired.");return;}fishing.open(player,holder.giver);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);}));return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}var offered=directory.findSite(site).orElseThrow();var type=event.isRightClick()?AdventureQuestState.UnitType.PARTY:AdventureQuestState.UnitType.SOLO;if(!offered.participationMode().permits(type)){player.sendMessage("Quest acceptance refused: this site is "+offered.participationMode().value()+".");return;}if(type==AdventureQuestState.UnitType.PARTY&&(context.partyId==null||!context.leader)){player.sendMessage("Only the active party leader may accept a party quest.");return;}var unit=type==AdventureQuestState.UnitType.SOLO?context.character:context.partyId;var members=type==AdventureQuestState.UnitType.SOLO?Set.of(context.character):context.members;directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&¤t.active.equals(context.active)&¤t.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&¤t.members.equals(context.members));if(!same){directory.abandon(quest.id(),quest.unit()).whenComplete((ignored,rollbackFailure)->schedule(()->player.sendMessage(rollbackFailure==null?"Quest acceptance refused: the character or party changed.":"Quest acceptance is pending safe cancellation; retry after recovery.")));return;}var location=directory.findSite(site).orElseThrow();if(quest.fishing()!=null&&fishingEvents!=null)fishingEvents.accepted(quest.id(),fishingEvents.actor(player),quest);refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+quest.expiresAt()+". Follow the Quest Compass.");}));}
|
||||
private void claim(Player player,Context expected,UUID catalog,UUID site){directory.beginClaim(catalog,site,expected.character).whenComplete((operation,failure)->schedule(()->{if(failure!=null||operation.isEmpty()){player.sendMessage("Reward claim failed; retained rewards are unchanged.");return;}var current=context(player);if(current==null||!current.active.equals(expected.active)||!current.character.equals(operation.orElseThrow().character())){player.sendMessage("Reward claim paused: actively play the exact living character and try again.");return;}var record=sessions.currentRecord(current.active).orElse(null);if(record==null){player.sendMessage("Reward claim paused: the current character changed.");return;}attemptClaim(player,current.active,operation.orElseThrow(),record);}));}
|
||||
private void attemptClaim(Player player,EquipmentSessions.Active active,AdventureQuestState.RewardClaim operation,games.dmg.fantasystick.persistence.CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){attemptClaim(player,active,operation,award.record().orElseThrow());return;}if(award.record().isEmpty()){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}var saved=award.record().orElseThrow();directory.finishClaim(operation.id()).whenComplete((finished,finishFailure)->schedule(()->{if(finishFailure!=null||!finished){player.sendMessage("Reward claim is pending recovery; retry at a Quest Giver.");return;}var holder=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE&&holder!=null)sessions.acknowledgeProgress(holder,saved.revision().subtract(BigInteger.ONE),saved);var currentPlayer=holder==null?null:plugin.getServer().getPlayer(holder.player());var current=currentPlayer==null?null:context(currentPlayer);if(current!=null&¤t.character.equals(operation.character())){currentPlayer.sendMessage("Claimed "+operation.xp()+" fantasy XP and "+operation.coins()+" character coins.");open(currentPlayer);}}));});}
|
||||
public boolean trackingAvailable(Player player){var context=context(player);return context!=null&&directory.activeFor(context.character,context.partyId).filter(quest->quest.snapshot().contains(context.character)).isPresent();}
|
||||
@@ -86,13 +112,72 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private void startChampion(AdventureQuestState.Site site,UUID catalog){if(closed||attempts.containsKey(site.id())||starting.contains(site.id()))return;var world=plugin.getServer().getWorld(site.world());if(world==null||!world.isChunkLoaded(section(site.x()),section(site.z())))return;starting.add(site.id());try{var rules=settings.rules();var attempt=new Attempt(site.id(),catalog,site.type(),arena(site),rules.resetSeconds());attempt.bossBar=plugin.getServer().createBossBar("Daybreak Champion",BarColor.RED,BarStyle.SOLID);attempts.put(site.id(),attempt);world.spawn(new Location(world,site.x(),site.y(),site.z()),Vindicator.class,champion->{configureEnemy(champion,attempt);var maximum=Objects.requireNonNull(champion.getAttribute(Attribute.MAX_HEALTH));maximum.setBaseValue(rules.championHealth());champion.setHealth(rules.championHealth());Objects.requireNonNull(champion.getAttribute(Attribute.ATTACK_DAMAGE)).setBaseValue(rules.championDamage());champion.customName(net.kyori.adventure.text.Component.text("Daybreak Champion"));champion.setCustomNameVisible(true);attempt.champion=champion;});syncBossBar(attempt);}catch(RuntimeException failure){var attempt=attempts.remove(site.id());if(attempt!=null)cleanup(attempt);plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion attempt could not start at site "+site.id(),failure);}finally{starting.remove(site.id());}}
|
||||
private void spawnEnemy(World world,AdventureQuestState.Site site,org.bukkit.entity.EntityType type,Attempt attempt,int index){var location=new Location(world,site.x()+(index%3)-1,site.y(),site.z()+(index/3));switch(type){case HUSK->world.spawn(location,Husk.class,mob->configureEnemy(mob,attempt));case PILLAGER->world.spawn(location,Pillager.class,mob->configureEnemy(mob,attempt));case VINDICATOR->world.spawn(location,Vindicator.class,mob->configureEnemy(mob,attempt));case WITCH->world.spawn(location,Witch.class,mob->configureEnemy(mob,attempt));default->throw new IllegalArgumentException("Unsupported camp entity");}}
|
||||
private void configureEnemy(Mob mob,Attempt attempt){mob.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,ENEMY);mob.getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,attempt.site.toString());mob.getPersistentDataContainer().set(catalogKey,PersistentDataType.STRING,attempt.catalog.toString());mob.getPersistentDataContainer().set(attemptKey,PersistentDataType.STRING,attempt.id.toString());var kind=attempt.type==AdventureQuestState.SiteType.CHAMPION?games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP;mob.getPersistentDataContainer().set(encounterKindKey,PersistentDataType.STRING,kind.name());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}attempt.entities.add(mob.getUniqueId());}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(enemy(event.getEntity())){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),site(event.getEntity()))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID site=site(by.getDamager());if(!(event.getEntity() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(enemy(event.getEntity())&&encounterOrigin(event.getEntity()).isPresent()){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),site(event.getEntity()))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID site=site(by.getDamager());if(!(event.getEntity() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void accepted(EntityDamageByEntityEvent event){if(!enemy(event.getEntity())||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;var player=source(event);if(player==null)return;var quest=eligible(player,site(event.getEntity()));if(quest!=null){var attempt=attempts.get(quest.site());if(attempt!=null){attempt.represented.add(quest.id());if(attempt.champion==event.getEntity()&&attempt.bossBar!=null){double maximum=Objects.requireNonNull(attempt.champion.getAttribute(Attribute.MAX_HEALTH)).getValue();attempt.bossBar.setProgress(Math.max(0,Math.min(1,(attempt.champion.getHealth()-event.getFinalDamage())/maximum)));}}}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void death(EntityDeathEvent event){if(!enemy(event.getEntity()))return;event.getDrops().clear();event.setDroppedExp(0);UUID site=site(event.getEntity());var attempt=site==null?null:attempts.get(site);if(attempt==null)return;attempt.entities.remove(event.getEntity().getUniqueId());if(attempt.entities.isEmpty())clear(attempt);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void death(EntityDeathEvent event){
|
||||
if(!enemy(event.getEntity()))return;
|
||||
UUID site=site(event.getEntity());var attempt=site==null?null:attempts.get(site);
|
||||
if(attempt==null||!attempt.entities.remove(event.getEntity().getUniqueId()))return;
|
||||
event.getDrops().clear();event.setDroppedExp(0);
|
||||
if(attempt.type==AdventureQuestState.SiteType.CHAMPION||attempt.type==AdventureQuestState.SiteType.CAMP){
|
||||
var kind=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.valueOf(attempt.type.name());
|
||||
dyingOrigins.put(event.getEntity().getUniqueId(),
|
||||
new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,attempt.id));
|
||||
if(dyingOrigins.size()>512)dyingOrigins.remove(dyingOrigins.keySet().iterator().next());
|
||||
}
|
||||
if(fishingEvents!=null&&(attempt.type==AdventureQuestState.SiteType.CHAMPION
|
||||
||attempt.type==AdventureQuestState.SiteType.CAMP)){
|
||||
var target=event.getEntity();var data=new LinkedHashMap<String,Object>();
|
||||
var kind=attempt.type.name().toLowerCase(Locale.ROOT);
|
||||
data.put("resolution","encounter_suppressed");data.put("managed_origin_kind",kind);
|
||||
data.put("managed_origin_source",attempt.id.toString());data.put("target_id",target.getUniqueId().toString());
|
||||
data.put("target_type",target.getType()==null?"unavailable":target.getType().name().toLowerCase(Locale.ROOT));
|
||||
data.put("site_id",attempt.site.toString());data.put("catalog_id",attempt.catalog.toString());
|
||||
data.put("defeat_location",trophyLocation(target.getLocation()));
|
||||
data.put("spacing_status","managed_origin_exempt");
|
||||
data.put("ordinary_history","not_consulted_or_updated");
|
||||
data.put("experience_pool","0");data.put("coin_pool","0");
|
||||
data.put("allocated_experience","0");data.put("allocated_coins","0");
|
||||
data.put("applied_experience","0");data.put("applied_coins","0");
|
||||
data.put("suppressed_experience","0");data.put("suppressed_coins","0");
|
||||
data.put("suppression_reason","champion_and_camp_per_enemy_payout_disabled");
|
||||
var operation=UUID.nameUUIDFromBytes(("managed-suppressed:"+attempt.id+":"+target.getUniqueId())
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
fishingEvents.durable("combat.reward_resolved",operation,
|
||||
new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null),data);
|
||||
}
|
||||
if(attempt.entities.isEmpty()){attempt.defeatedEntity=event.getEntity().getUniqueId();
|
||||
attempt.defeatLocation=trophyLocation(event.getEntity().getLocation());attempt.defeatedAt=clock.instant();
|
||||
clear(attempt);}}
|
||||
private static Map<String,Object> trophyLocation(Location location){
|
||||
if(location==null||location.getWorld()==null)return Map.of("status","unavailable");
|
||||
try{var world=location.getWorld();if(world.getUID()==null)return Map.of("status","unavailable");
|
||||
var value=new LinkedHashMap<String,Object>();value.put("world_id",world.getUID().toString());
|
||||
value.put("world_name",world.getName()==null?"unavailable":world.getName());
|
||||
value.put("x",location.getX());value.put("y",location.getY());value.put("z",location.getZ());
|
||||
return Map.copyOf(value);
|
||||
}catch(RuntimeException unavailable){return Map.of("status","unavailable");}
|
||||
}
|
||||
private void clear(Attempt attempt){attempts.remove(attempt.site);if(attempt.bossBar!=null)attempt.bossBar.removeAll();readyAt.put(attempt.site,clock.instant().plusSeconds(attempt.resetSeconds));for(UUID questId:Set.copyOf(attempt.represented)){var quest=directory.findQuest(questId).orElse(null);if(quest==null)continue;var recipients=recipients(quest,attempt.site,attempt.arena);if(!recipients.isEmpty())award(quest,recipients,attempt);} }
|
||||
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site){var value=directory.findSite(site).orElse(null);return value==null?List.of():recipients(quest,site,arena(value));}
|
||||
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return List.of();var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(directory.characterClaimed(quest.catalog(),quest.site(),character))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),value,arena))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
|
||||
private void award(AdventureQuestState.Quest quest,List<UUID> recipients,Attempt attempt){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);if((attempt.type==AdventureQuestState.SiteType.CHAMPION||attempt.type==AdventureQuestState.SiteType.CAMP)&&fishing!=null){var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);var site=directory.findSite(quest.site()).orElse(null);if(carrier==null||site==null){plugin.getLogger().warning("Champion trophy carrier unavailable for quest "+quest.id());return;}var trophyType=attempt.type==AdventureQuestState.SiteType.CHAMPION?FishingState.TrophyType.CHAMPION:FishingState.TrophyType.CAMP;UUID trophy=UUID.nameUUIDFromBytes((trophyType.name().toLowerCase(java.util.Locale.ROOT)+":"+quest.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8));String trophyName=trophyType==FishingState.TrophyType.CHAMPION?"Daybreak Champion Head":"Spoils of "+site.displayName();var plan=new FishingState.Trophy(trophy,trophyType,carrier,quest.catalog(),quest.site(),quest.id(),attempt.id,trophyName,site.displayName(),rewards);fishing.earnTrophy(plan).whenComplete((earned,failure)->schedule(()->{if(failure!=null||!earned){plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion trophy persistence failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: its trophy is stored with the captured carrier.");}refreshTrackingPlayers();}));return;}var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));directory.bankAndComplete(quest.id(),quest.id(),rewards).whenComplete((banked,failure)->schedule(()->{if(failure!=null||!banked){plugin.getLogger().log(java.util.logging.Level.WARNING,"Adventure completion reward banking failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: rewards are ready at any Quest Giver.");}refreshTrackingPlayers();}));}
|
||||
private void award(AdventureQuestState.Quest quest,List<UUID> recipients,Attempt attempt){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);if((attempt.type==AdventureQuestState.SiteType.CHAMPION||attempt.type==AdventureQuestState.SiteType.CAMP)&&fishing!=null){var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);var site=directory.findSite(quest.site()).orElse(null);if(carrier==null||site==null){plugin.getLogger().warning("Champion trophy carrier unavailable for quest "+quest.id());return;}var trophyType=attempt.type==AdventureQuestState.SiteType.CHAMPION?FishingState.TrophyType.CHAMPION:FishingState.TrophyType.CAMP;UUID trophy=UUID.nameUUIDFromBytes((trophyType.name().toLowerCase(java.util.Locale.ROOT)+":"+quest.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8));String trophyName=trophyType==FishingState.TrophyType.CHAMPION?"Daybreak Champion Head":"Spoils of "+site.displayName();var audit=new LinkedHashMap<String,String>();
|
||||
audit.put(trophyType==FishingState.TrophyType.CHAMPION?"champion_id":"final_enemy_id",
|
||||
attempt.defeatedEntity==null?"unavailable":attempt.defeatedEntity.toString());
|
||||
audit.put("defeated_at",attempt.defeatedAt==null?"unavailable":attempt.defeatedAt.truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString());
|
||||
if(trophyType==FishingState.TrophyType.CAMP){
|
||||
audit.put("arena_shape",attempt.arena.shape().name().toLowerCase(Locale.ROOT));
|
||||
audit.put("arena_x_blocks",Integer.toString(attempt.arena.xBlocks()));
|
||||
audit.put("arena_z_blocks",Integer.toString(attempt.arena.zBlocks()));
|
||||
}
|
||||
for(var entry:attempt.defeatLocation.entrySet())
|
||||
audit.put("defeat_"+entry.getKey(),String.valueOf(entry.getValue()));
|
||||
audit.put("unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));
|
||||
audit.put("unit_id",quest.unit().toString());
|
||||
audit.put("accepted_roster",String.join(",",quest.snapshot().stream().sorted().map(UUID::toString).toList()));
|
||||
var plan=new FishingState.Trophy(trophy,trophyType,carrier,quest.catalog(),quest.site(),quest.id(),
|
||||
attempt.id,trophyName,site.displayName(),rewards,FishingState.TrophyPhase.PREPARED,audit);
|
||||
fishing.earnTrophyWithEvidence(plan).whenComplete((earned,failure)->schedule(()->{if(failure!=null||earned==null||!earned.acknowledged()){plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion trophy persistence failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: its trophy is stored with the captured carrier.");}refreshTrackingPlayers();}));return;}var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));directory.bankAndComplete(quest.id(),quest.id(),rewards).whenComplete((banked,failure)->schedule(()->{if(failure!=null||!banked){plugin.getLogger().log(java.util.logging.Level.WARNING,"Adventure completion reward banking failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: rewards are ready at any Quest Giver.");}refreshTrackingPlayers();}));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void splash(org.bukkit.event.entity.PotionSplashEvent event){if(!managed(event.getPotion()))return;UUID site=site(event.getPotion());for(var affected:List.copyOf(event.getAffectedEntities()))if(!(affected instanceof Player player)||site==null||eligible(player,site)==null)event.setIntensity(affected,0);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void target(EntityTargetLivingEntityEvent event){if(enemy(event.getEntity())&&event.getTarget()!=null){UUID site=site(event.getEntity());if(!(event.getTarget() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void projectile(org.bukkit.event.entity.ProjectileLaunchEvent event){if(event.getEntity().getShooter() instanceof Entity shooter&&enemy(shooter)){event.getEntity().getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,PROJECTILE);event.getEntity().getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,Objects.requireNonNull(site(shooter)).toString());}}
|
||||
@@ -123,5 +208,5 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private static UUID parse(String value){try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
|
||||
private static UUID id(Entity entity,NamespacedKey key){return parse(entity.getPersistentDataContainer().get(key,PersistentDataType.STRING));}
|
||||
private static <T>List<T> safe(Collection<T> values){return values==null?List.of():List.copyOf(values);}
|
||||
@Override public void close(){for(var player:plugin.getServer().getOnlinePlayers()){sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(sessions::restoreBook);}closed=true;if(pulseTask!=null)pulseTask.cancel();for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
|
||||
@Override public void close(){for(var player:plugin.getServer().getOnlinePlayers()){sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(sessions::restoreBook);}closed=true;if(pulseTask!=null)pulseTask.cancel();for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();dyingOrigins.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
|
||||
}
|
||||
@@ -24,36 +24,337 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
private record HookClaim(UUID player,UUID session,UUID character,UUID quest,UUID hook,BigInteger sampledXp){}
|
||||
private record Holder(UUID player,UUID session,UUID character,UUID nonce,int page) implements InventoryHolder{public Inventory getInventory(){return null;}}
|
||||
private final Plugin plugin;private final AdventureQuestDirectory adventures;private final FishingDirectory fishing;private final AdventureQuestSettings settings;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final Consumer<Failure> failures;
|
||||
private final NamespacedKey actionKey;private final Map<UUID,HookClaim> hooks=new HashMap<>();private final Map<UUID,UUID> menus=new HashMap<>();private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
|
||||
private final NamespacedKey actionKey;private final Map<UUID,HookClaim> hooks=new HashMap<>();private final Map<UUID,UUID> menus=new HashMap<>();
|
||||
private final Map<UUID,AdventureQuestState.Giver> menuGivers=new HashMap<>();
|
||||
/** Only recently retained native catches may reappear in stale callbacks; never delete their already-owned items. */
|
||||
private final Map<UUID,ArrayDeque<UUID>> retainedHooks=new HashMap<>();
|
||||
private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
|
||||
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
|
||||
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
|
||||
public FishingController(Plugin plugin,AdventureQuestDirectory adventures,FishingDirectory fishing,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,Consumer<Failure> failures){this.plugin=Objects.requireNonNull(plugin);this.adventures=Objects.requireNonNull(adventures);this.fishing=Objects.requireNonNull(fishing);this.settings=Objects.requireNonNull(settings);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.random=Objects.requireNonNull(random);this.failures=Objects.requireNonNull(failures);actionKey=new NamespacedKey(plugin,"fishing_menu_action");}
|
||||
public void install(){for(var pending:fishing.pendingCatches())recoverCatch(pending);for(var trophy:fishing.snapshot().trophies())reconcileTrophy(trophy);for(var submission:fishing.pendingSubmissions())recoverSubmission(submission);for(var claim:fishing.snapshot().claims())recoverClaim(claim);pulseTask=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20,20);}
|
||||
private void pulse(){if(closed)return;for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId())&&!nativeUse(player,player.getInventory().getItemInMainHand()))cleanup(player);}
|
||||
@Override public java.util.concurrent.CompletionStage<Void> reset(AdventureQuestState.Reset reset){for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();menus.clear();return java.util.concurrent.CompletableFuture.completedFuture(null);}
|
||||
@Override public java.util.concurrent.CompletionStage<Void> reset(AdventureQuestState.Reset reset){for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();menus.clear();menuGivers.clear();return java.util.concurrent.CompletableFuture.completedFuture(null);}
|
||||
public boolean nativeUse(Player player,ItemStack item){if(closed||adventures.snapshot().reset()!=null||AdventureQuestNavigation.fishingQuest(item)==null)return false;var selected=sessions.selectedControl(player,item).orElse(null);if(selected==null||selected.slot()!=0)return false;var context=context(player);var quest=context==null?null:adventures.findQuest(AdventureQuestNavigation.fishingQuest(item)).orElse(null);return quest!=null&&eligible(context,quest)&&quest.fishing()!=null&&quest.site().equals(site(item));}
|
||||
public BigInteger quantity(UUID character,UUID item){return fishing.quantity(character,item);}
|
||||
public java.util.concurrent.CompletionStage<Boolean> earnTrophy(FishingState.Trophy trophy){return fishing.prepareTrophy(trophy).thenCompose(status->{if(status==FishingDirectory.TrophyPrepareStatus.REDEEMED)return java.util.concurrent.CompletableFuture.completedFuture(true);var retained=fishing.trophy(trophy.id()).orElseThrow();return adventures.complete(retained.quest(),retained.rewards().keySet()).thenCompose(ignored->fishing.commitTrophy(retained.id())).thenApply(committed->committed!=FishingDirectory.TrophyCommitStatus.NOT_FOUND);});}
|
||||
private void reconcileTrophy(FishingState.Trophy trophy){if(trophy.phase()==FishingState.TrophyPhase.EARNED)return;adventures.complete(trophy.quest(),trophy.rewards().keySet()).thenCompose(ignored->fishing.commitTrophy(trophy.id())).whenComplete((ignored,failure)->{if(failure!=null)report(trophy.id(),trophy.carrier(),"trophy-reconcile",failure);});}
|
||||
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.MONITOR)public void quit(org.bukkit.event.player.PlayerQuitEvent event){cleanup(event.getPlayer());}
|
||||
public record TrophyEarned(boolean acknowledged,boolean newlyEarned,FishingState.Trophy receipt) {}
|
||||
public java.util.concurrent.CompletionStage<Boolean> earnTrophy(FishingState.Trophy trophy){
|
||||
return earnTrophyWithEvidence(trophy).thenApply(TrophyEarned::acknowledged);
|
||||
}
|
||||
public java.util.concurrent.CompletionStage<TrophyEarned> earnTrophyWithEvidence(FishingState.Trophy trophy){
|
||||
return fishing.prepareTrophy(trophy).thenCompose(status->{
|
||||
if(status==FishingDirectory.TrophyPrepareStatus.REDEEMED)
|
||||
return java.util.concurrent.CompletableFuture.completedFuture(new TrophyEarned(true,false,null));
|
||||
var retained=fishing.trophy(trophy.id()).orElseThrow();
|
||||
return adventures.complete(retained.quest(),retained.rewards().keySet())
|
||||
.thenCompose(ignored->fishing.commitTrophy(retained.id()))
|
||||
.thenApply(committed->{
|
||||
var earned=committed==FishingDirectory.TrophyCommitStatus.NOT_FOUND?retained:retained.earned();
|
||||
if(committed==FishingDirectory.TrophyCommitStatus.APPLIED&&fishingEvents!=null)
|
||||
fishingEvents.trophyEarned(earned);
|
||||
return new TrophyEarned(committed!=FishingDirectory.TrophyCommitStatus.NOT_FOUND,
|
||||
committed==FishingDirectory.TrophyCommitStatus.APPLIED,earned);
|
||||
});
|
||||
});
|
||||
}
|
||||
private void reconcileTrophy(FishingState.Trophy trophy){
|
||||
if(trophy.phase()==FishingState.TrophyPhase.EARNED)return;
|
||||
adventures.complete(trophy.quest(),trophy.rewards().keySet())
|
||||
.thenCompose(ignored->fishing.commitTrophy(trophy.id()))
|
||||
.whenComplete((committed,failure)->{
|
||||
if(failure!=null)report(trophy.id(),trophy.carrier(),"trophy-reconcile",failure);
|
||||
else if(committed==FishingDirectory.TrophyCommitStatus.APPLIED&&fishingEvents!=null)
|
||||
fishingEvents.trophyEarned(trophy.earned());
|
||||
});
|
||||
}
|
||||
public void open(Player player){open(player,0,null);}
|
||||
public void open(Player player,AdventureQuestState.Giver giver){open(player,0,giver);}
|
||||
private void open(Player player,int requestedPage,AdventureQuestState.Giver giver){
|
||||
if(giver==null)menuGivers.remove(player.getUniqueId());else menuGivers.put(player.getUniqueId(),giver);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)),menuGivers.get(player.getUniqueId()));}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=false)
|
||||
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;
|
||||
var auditBridge=fishingEvents;var auditActor=auditBridge==null?null:auditBridge.actor(player);
|
||||
UUID action=UUID.randomUUID();boolean priorCancelled=event.isCancelled();
|
||||
var observation=new LinkedHashMap<String,Object>();
|
||||
observation.put("action_kind","fishing");observation.put("state",event.getState().name());
|
||||
observation.put("hook_id",event.getHook().getUniqueId().toString());
|
||||
observation.put("quest_id",quest.id().toString());observation.put("site_id",quest.site().toString());
|
||||
observation.put("candidate_catch_id",UUID.nameUUIDFromBytes((quest.id()+":"+event.getHook().getUniqueId())
|
||||
.getBytes(StandardCharsets.UTF_8)).toString());
|
||||
observation.put("fisher_location",locationMap(capture(player.getLocation())));
|
||||
observation.put("bobber_location",locationMap(capture(event.getHook().getLocation())));
|
||||
observation.put("prior_cancelled",priorCancelled);
|
||||
if(auditBridge!=null)auditBridge.transientEvent("action.observed",action,auditActor,observation);
|
||||
var outcome=new String[]{"native_continuation"};
|
||||
try {
|
||||
if(priorCancelled){outcome[0]="prior_cancelled";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->{outcome[0]="entity_pull_refused";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())){
|
||||
outcome[0]="stale_or_foreign_hook";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)){
|
||||
outcome[0]="no_native_item";refuseCatch(event,"Quest catch refused: no native fish item was caught.");return;
|
||||
}
|
||||
var caughtSite=adventures.findSite(quest.site()).orElse(null);
|
||||
if(caughtSite==null||!inside(player.getLocation(),quest)||!inside(event.getHook().getLocation(),quest)){
|
||||
outcome[0]="outside_captured_area";refuseCatch(event,"Quest catch refused: keep yourself and the bobber inside the fishing area.");return;
|
||||
}
|
||||
ItemStack nativeItem;
|
||||
try{nativeItem=caught.getItemStack();}catch(RuntimeException unavailable){nativeItem=null;}
|
||||
event.setExpToDrop(0);event.getHook().remove();
|
||||
BigInteger xp=claim.sampledXp();double sample=random.nextDouble(100d);boolean awarded=sample<quest.fishing().chancePercent().doubleValue();
|
||||
if(awarded){outcome[0]="quest_item_pending_acknowledgment";event.setCancelled(true);caught.remove();}
|
||||
else {outcome[0]="ordinary_native_retained_pending_acknowledgment";retain(player.getUniqueId(),claim.hook());} // Let the original native catch follow ordinary collection.
|
||||
UUID operation=UUID.nameUUIDFromBytes((quest.id()+":"+claim.hook()).getBytes(StandardCharsets.UTF_8));
|
||||
var audit=new LinkedHashMap<String,String>();
|
||||
audit.put("player_id",player.getUniqueId().toString());audit.put("player_name",player.getName());
|
||||
audit.put("character_name",context.record().name());audit.put("session_id",context.active().session().toString());
|
||||
audit.put("catalog_id",quest.catalog().toString());audit.put("hook_id",claim.hook().toString());
|
||||
audit.put("radius",Integer.toString(quest.fishing().radius()));
|
||||
audit.put("chance_percent",quest.fishing().chancePercent().toPlainString());
|
||||
audit.put("sample_percent",Double.toString(sample));
|
||||
audit.put("native_item_disposition",awarded?"suppressed":"retained");
|
||||
audit.put("native_xp_disposition","suppressed");
|
||||
audit.put("native_item_type",nativeItem==null?"unavailable":nativeItem.getType().name());
|
||||
audit.put("native_item_amount",nativeItem==null?"unavailable":Integer.toString(nativeItem.getAmount()));
|
||||
audit.put("fisher_location",capture(player.getLocation()));
|
||||
audit.put("bobber_location",capture(event.getHook().getLocation()));
|
||||
audit.put("site_world_id",caughtSite.world().toString());
|
||||
audit.put("site_center_x",Double.toString(caughtSite.x()));
|
||||
audit.put("site_center_z",Double.toString(caughtSite.z()));
|
||||
audit.put("geometry_decision","both_inside_same_world_circle");
|
||||
var plan=new FishingState.Catch(operation,context.record().id(),quest.id(),quest.site(),quest.fishing().itemId(),quest.fishing().itemName(),xp,awarded,audit);
|
||||
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());outcome[0]="no_successful_item_reel";}
|
||||
}
|
||||
} finally {
|
||||
if(auditBridge!=null){var resolved=new LinkedHashMap<String,Object>(observation);
|
||||
resolved.put("outcome",outcome[0]);resolved.put("final_cancelled",event.isCancelled());
|
||||
resolved.put("native_xp",event.getExpToDrop());
|
||||
auditBridge.transientEvent("action.resolved",action,auditActor,resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
private static String capture(Location value){
|
||||
if(value==null||value.getWorld()==null)return "unavailable";
|
||||
return value.getWorld().getUID()+","+value.getX()+","+value.getY()+","+value.getZ();
|
||||
}
|
||||
private static Map<String,Object> locationMap(String encoded){
|
||||
if(encoded==null||encoded.equals("unavailable"))return Map.of("status","unavailable");
|
||||
try{var parts=encoded.split(",",-1);if(parts.length!=4)throw new IllegalArgumentException();
|
||||
return Map.of("world_id",UUID.fromString(parts[0]).toString(),
|
||||
"x",Double.parseDouble(parts[1]),"y",Double.parseDouble(parts[2]),"z",Double.parseDouble(parts[3]));
|
||||
}catch(RuntimeException invalid){return Map.of("status","unavailable");}
|
||||
}
|
||||
private static Map<String,Object> giverLocation(AdventureQuestState.Giver giver){
|
||||
return Map.of("world_id",giver.world().toString(),"x",giver.x(),"y",giver.y(),"z",giver.z());
|
||||
}
|
||||
private void retain(UUID player,UUID hook){var recent=retainedHooks.computeIfAbsent(player,ignored->new ArrayDeque<>());recent.addLast(hook);while(recent.size()>16)recent.removeFirst();}
|
||||
private void refuseCatch(PlayerFishEvent event,String reason){event.setCancelled(true);event.setExpToDrop(0);var recent=retainedHooks.get(event.getPlayer().getUniqueId());if(event.getCaught()!=null&&(recent==null||!recent.contains(event.getHook().getUniqueId())))event.getCaught().remove();event.getPlayer().sendMessage(reason);}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void quit(org.bukkit.event.player.PlayerQuitEvent event){cleanup(event.getPlayer());retainedHooks.remove(event.getPlayer().getUniqueId());menuGivers.remove(event.getPlayer().getUniqueId());}
|
||||
@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);}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void sneakBook(org.bukkit.event.player.PlayerInteractEvent event){if(event.getPlayer().isSneaking()&&AdventureQuestNavigation.fishingQuest(event.getItem())!=null)cleanup(event.getPlayer());}
|
||||
private void cleanup(Player player){hooks.remove(player.getUniqueId());try{var hook=player.getFishHook();if(hook!=null)hook.remove();}catch(RuntimeException ignored){}}
|
||||
private void recoverCatch(FishingState.Catch plan){characters.find(plan.character()).whenComplete((record,failure)->{if(failure!=null||record.isEmpty()){if(failure!=null)report(plan,"load",failure);return;}awardCatch(plan,record.orElseThrow());});}
|
||||
private void awardCatch(FishingState.Catch plan,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),plan.id(),plan.xp(),BigInteger.ZERO,progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(plan,"award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardCatch(plan,award.record().orElseThrow());return;}if(award.record().isEmpty()){return;}var saved=award.record().orElseThrow();fishing.completeCatch(plan.id()).whenComplete((status,completeFailure)->{if(completeFailure!=null){report(plan,"complete",completeFailure);return;}schedule(()->{var active=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(active!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(active,saved.revision().subtract(BigInteger.ONE),saved);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null&&status==FishingDirectory.CompleteStatus.APPLIED){String result=plan.itemAwarded()?"Caught "+plan.itemName()+".":"The catch yielded no quest item.";String xp=award.status()==CharacterStorage.CombatAwardStatus.AT_CAP?" Fantasy XP remained at the level cap.":award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?" Its Fantasy XP was already recorded.":" Earned "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" Fantasy XP.";player.sendMessage(result+xp);}});});});}
|
||||
private void submit(Player player,Context context){var quest=fishingQuest(context);if(quest==null){player.sendMessage("Fishing submission refused: no eligible accepted fishing quest.");return;}var beneficiaries=beneficiaries(context,quest);if(beneficiaries.isEmpty()||!beneficiaries.contains(context.record().id())){player.sendMessage("Fishing submission refused: party eligibility changed.");return;}var unit=new RewardAllocator.Unit<UUID>(quest.unit(),beneficiaries);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:beneficiaries)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID operation=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+context.record().id()).getBytes(StandardCharsets.UTF_8));var plan=new FishingState.Submission(operation,quest.id(),quest.catalog(),quest.site(),context.record().id(),quest.fishing().itemId(),quest.fishing().required(),rewards,FishingState.SubmissionPhase.PREPARED);fishing.prepareSubmission(plan).whenComplete((status,failure)->{if(failure!=null){report(operation,context.record().id(),"submit-prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.INSUFFICIENT||status==FishingDirectory.PrepareStatus.COMPLETED){schedule(()->player.sendMessage(status==FishingDirectory.PrepareStatus.INSUFFICIENT?"Fishing submission refused: the full required quantity is not held.":"Fishing submission refused: this character already completed the offered generation."));return;}commitSubmission(plan,player);});}
|
||||
private void awardCatch(FishingState.Catch plan,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),plan.id(),plan.xp(),BigInteger.ZERO,progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(plan,"award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardCatch(plan,award.record().orElseThrow());return;}if(award.record().isEmpty()){return;}var saved=award.record().orElseThrow();fishing.completeCatchWithEvidence(plan.id()).whenComplete((receipt,completeFailure)->{if(completeFailure!=null){report(plan,"complete",completeFailure);return;}if(receipt.status()==FishingDirectory.CompleteStatus.APPLIED&&fishingEvents!=null){
|
||||
var data=new LinkedHashMap<String,Object>(plan.audit());
|
||||
data.put("fisher_location",locationMap(plan.audit().get("fisher_location")));
|
||||
data.put("bobber_location",locationMap(plan.audit().get("bobber_location")));
|
||||
if(plan.audit().containsKey("radius"))data.put("radius",plan.audit().get("radius"));
|
||||
data.put("catch_id",plan.id().toString());data.put("quest_id",plan.quest().toString());
|
||||
data.put("site_id",plan.site().toString());data.put("item_id",plan.item().toString());
|
||||
data.put("item_name",plan.itemName());data.put("item_awarded",plan.itemAwarded());
|
||||
data.put("sampled_xp",plan.xp().toString());
|
||||
data.put("allocated_xp",plan.xp().toString()); // Personal catch XP, never a party split.
|
||||
data.put("stock_before",receipt.stockBefore().toString());
|
||||
data.put("stock_after",receipt.stockAfter().toString());
|
||||
data.put("award_status",award.status().name().toLowerCase(java.util.Locale.ROOT));
|
||||
if(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE)data.put("applied_xp_status","previously_applied_receipt");
|
||||
else {var applied=saved.experience().subtract(record.experience()).max(BigInteger.ZERO);
|
||||
data.put("applied_xp",applied.toString());data.put("lost_xp",plan.xp().subtract(applied).max(BigInteger.ZERO).toString());}
|
||||
fishingEvents.durable("fishing.catch_resolved",plan.id(),
|
||||
fishingEvents.actorFromCatch(plan),data);
|
||||
}schedule(()->{var active=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(active!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(active,saved.revision().subtract(BigInteger.ONE),saved);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null&&receipt.status()==FishingDirectory.CompleteStatus.APPLIED){String result=plan.itemAwarded()?"Caught "+plan.itemName()+".":"The catch yielded no quest item.";String xp=award.status()==CharacterStorage.CombatAwardStatus.AT_CAP?" Fantasy XP remained at the level cap.":award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?" Its Fantasy XP was already recorded.":" Earned "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" Fantasy XP.";player.sendMessage(result+xp);}});});});}
|
||||
private void emitSubmission(FishingState.Submission plan,games.dmg.fantasystick.events.FishingEventBridge.Actor actor,
|
||||
FishingDirectory.SubmissionReceipt receipt){
|
||||
if(fishingEvents==null)return;
|
||||
var data=new LinkedHashMap<String,Object>();
|
||||
data.put("submission_id",plan.id().toString());data.put("quest_id",plan.quest().toString());
|
||||
data.put("catalog_id",plan.catalog().toString());data.put("site_id",plan.site().toString());
|
||||
data.put("item_id",plan.item().toString());data.put("submitter_id",plan.submitter().toString());
|
||||
data.put("required",plan.quantity().toString());data.put("consumed",plan.quantity().toString());
|
||||
data.put("stock_before",receipt.stockBefore().toString());
|
||||
data.put("stock_after",receipt.stockAfter().toString());
|
||||
data.putAll(plan.audit());
|
||||
data.remove("online_roster");
|
||||
data.put("roster",plan.rewards().keySet().stream().sorted().map(UUID::toString).toList());
|
||||
if(plan.audit().containsKey("submitter_location"))
|
||||
data.put("submitter_location",locationMap(plan.audit().get("submitter_location")));
|
||||
if(plan.audit().containsKey("giver_location"))
|
||||
data.put("giver_location",locationMap(plan.audit().get("giver_location")));
|
||||
if(!plan.audit().containsKey("unit_kind"))data.put("unit_kind","unavailable_legacy_submission");
|
||||
var beneficiaries=new ArrayList<Map<String,String>>();
|
||||
var online=plan.audit().containsKey("online_roster")?Set.of(plan.audit().get("online_roster").split(",")):Set.<String>of();
|
||||
plan.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->{
|
||||
var part=new LinkedHashMap<String,String>();
|
||||
part.put("character_id",entry.getKey().toString());
|
||||
part.put("online_at_submission",plan.audit().containsKey("online_roster")
|
||||
?Boolean.toString(online.contains(entry.getKey().toString())):"unavailable");
|
||||
part.put("allocated_xp",entry.getValue().xp().toString());
|
||||
part.put("allocated_coins",entry.getValue().coins().toString());
|
||||
part.put("receipt_id",plan.id().toString());
|
||||
if(entry.getValue().xp().signum()>0||entry.getValue().coins().signum()>0)
|
||||
part.put("allocation_id",UUID.nameUUIDFromBytes(("fishing:"+plan.id()+":"+entry.getKey())
|
||||
.getBytes(StandardCharsets.UTF_8)).toString());
|
||||
else part.put("allocation_status","zero_share_completion");
|
||||
beneficiaries.add(part);
|
||||
});
|
||||
data.put("beneficiaries",beneficiaries);
|
||||
fishingEvents.durable("adventure.items_submitted",plan.id(),actor==null
|
||||
?new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null):actor,data);
|
||||
}
|
||||
private void submit(Player player,Context context){var quest=fishingQuest(context);if(quest==null){player.sendMessage("Fishing submission refused: no eligible accepted fishing quest.");return;}var beneficiaries=beneficiaries(context,quest);if(beneficiaries.isEmpty()||!beneficiaries.contains(context.record().id())){player.sendMessage("Fishing submission refused: party eligibility changed.");return;}var unit=new RewardAllocator.Unit<UUID>(quest.unit(),beneficiaries);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:beneficiaries)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID operation=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+context.record().id()).getBytes(StandardCharsets.UTF_8));var submissionAudit=new LinkedHashMap<String,String>();
|
||||
submissionAudit.put("unit_kind",quest.unitType().name().toLowerCase(java.util.Locale.ROOT));
|
||||
submissionAudit.put("unit_id",quest.unit().toString());
|
||||
var onlineRoster=beneficiaries.stream().sorted().filter(character->{
|
||||
var holder=sessions.activeCharacter(character).filter(sessions::current).orElse(null);
|
||||
var found=holder==null?null:plugin.getServer().getPlayer(holder.player());
|
||||
return found!=null&&found.isOnline();
|
||||
}).map(UUID::toString).collect(java.util.stream.Collectors.joining(","));
|
||||
if(onlineRoster.length()<=8192)submissionAudit.put("online_roster",onlineRoster);
|
||||
else submissionAudit.put("online_roster_status","unavailable_large_roster");
|
||||
submissionAudit.put("submitter_session_id",context.active().session().toString());
|
||||
submissionAudit.put("player_id",player.getUniqueId().toString());
|
||||
submissionAudit.put("player_name",player.getName());
|
||||
submissionAudit.put("character_name",context.record().name());
|
||||
submissionAudit.put("submitter_location",capture(player.getLocation()));
|
||||
var giver=menuGivers.get(player.getUniqueId());
|
||||
if(giver!=null){submissionAudit.put("giver_id",giver.id().toString());
|
||||
submissionAudit.put("giver_location",giver.world()+","+giver.x()+","+giver.y()+","+giver.z());}
|
||||
var plan=new FishingState.Submission(operation,quest.id(),quest.catalog(),quest.site(),context.record().id(),
|
||||
quest.fishing().itemId(),quest.fishing().required(),rewards,FishingState.SubmissionPhase.PREPARED,submissionAudit);
|
||||
var auditActor=fishingEvents==null?null:fishingEvents.actor(player);fishing.prepareSubmission(plan).whenComplete((status,failure)->{if(failure!=null){report(operation,context.record().id(),"submit-prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.INSUFFICIENT||status==FishingDirectory.PrepareStatus.COMPLETED){schedule(()->player.sendMessage(status==FishingDirectory.PrepareStatus.INSUFFICIENT?"Fishing submission refused: the full required quantity is not held.":"Fishing submission refused: this character already completed the offered generation."));return;}commitSubmission(plan,player,auditActor);});}
|
||||
private void recoverSubmission(FishingState.Submission plan){if(plan.phase()==FishingState.SubmissionPhase.PREPARED)fishing.commitSubmission(plan.id()).whenComplete((status,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-commit",failure);return;}if(status==FishingDirectory.CommitStatus.APPLIED||status==FishingDirectory.CommitStatus.DUPLICATE)finishRecoveredSubmission(plan);});else finishRecoveredSubmission(plan);}
|
||||
private void finishRecoveredSubmission(FishingState.Submission plan){adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((ignored,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-quest",failure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submission-recovery-finish",finishFailure);});});}
|
||||
private void commitSubmission(FishingState.Submission plan,Player player){fishing.commitSubmission(plan.id()).whenComplete((status,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submit-commit",failure);return;}if(status==FishingDirectory.CommitStatus.INSUFFICIENT){schedule(()->player.sendMessage("Fishing submission refused: stock changed before commitment."));return;}if(status==FishingDirectory.CommitStatus.NOT_FOUND)return;adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((completed,questFailure)->{if(questFailure!=null){report(plan.id(),plan.submitter(),"quest-complete",questFailure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submit-finish",finishFailure);schedule(()->{player.sendMessage("Fishing quest submitted; earned shares are ready at any Quest Giver.");open(player);});});});});}
|
||||
private void redeemTrophy(Player player,Context context,String encoded){UUID trophy;try{trophy=UUID.fromString(encoded);}catch(IllegalArgumentException invalid){player.sendMessage("Trophy redemption refused: invalid authority.");return;}fishing.redeemTrophy(trophy,context.record().id()).whenComplete((status,failure)->schedule(()->{if(failure!=null){report(trophy,context.record().id(),"trophy-redeem",failure);player.sendMessage("Trophy redemption failed; the earned trophy is retained.");return;}if(status==FishingDirectory.TrophyRedeemStatus.APPLIED)player.sendMessage("Adventure trophy redeemed; frozen shares are ready for each beneficiary.");else player.sendMessage("Trophy redemption refused: the receipt is unavailable for this character.");open(player);}));}
|
||||
private void commitSubmission(FishingState.Submission plan,Player player,games.dmg.fantasystick.events.FishingEventBridge.Actor actor){fishing.commitSubmissionWithEvidence(plan.id()).whenComplete((receipt,failure)->{var status=receipt==null?null:receipt.status();if(failure!=null){report(plan.id(),plan.submitter(),"submit-commit",failure);return;}if(status==FishingDirectory.CommitStatus.INSUFFICIENT){schedule(()->player.sendMessage("Fishing submission refused: stock changed before commitment."));return;}if(status==FishingDirectory.CommitStatus.NOT_FOUND)return;if(status==FishingDirectory.CommitStatus.APPLIED)emitSubmission(plan,actor,receipt);adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((completed,questFailure)->{if(questFailure!=null){report(plan.id(),plan.submitter(),"quest-complete",questFailure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submit-finish",finishFailure);schedule(()->{player.sendMessage("Fishing quest submitted; earned shares are ready at any Quest Giver.");open(player,0,menuGivers.get(player.getUniqueId()));});});});});}
|
||||
private void redeemTrophy(Player player,Context context,String encoded){
|
||||
UUID trophy;try{trophy=UUID.fromString(encoded);}catch(IllegalArgumentException invalid){
|
||||
player.sendMessage("Trophy redemption refused: invalid authority.");return;}
|
||||
var captured=fishing.trophy(trophy).orElse(null);
|
||||
var giver=menuGivers.get(player.getUniqueId());
|
||||
var actor=fishingEvents==null?null:fishingEvents.actor(player);
|
||||
var interactionLocation=locationMap(capture(player.getLocation()));
|
||||
fishing.redeemTrophyWithEvidence(trophy,context.record().id()).whenComplete((receipt,failure)->schedule(()->{
|
||||
if(failure!=null){report(trophy,context.record().id(),"trophy-redeem",failure);
|
||||
player.sendMessage("Trophy redemption failed; the earned trophy is retained.");return;}
|
||||
var status=receipt==null?null:receipt.status();
|
||||
if(status==FishingDirectory.TrophyRedeemStatus.APPLIED){
|
||||
if(captured!=null&&(captured.type()==FishingState.TrophyType.CHAMPION
|
||||
||captured.type()==FishingState.TrophyType.CAMP)&&fishingEvents!=null){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("receipt_id",captured.id().toString());
|
||||
data.put("trophy_type",captured.type().name().toLowerCase(java.util.Locale.ROOT)+"_trophy");
|
||||
data.put("trophy_name",captured.name());
|
||||
if(captured.type()==FishingState.TrophyType.CAMP){
|
||||
data.put("final_enemy_id",captured.audit().getOrDefault("final_enemy_id","unavailable_legacy_capture"));
|
||||
data.put("clear_location",games.dmg.fantasystick.events.FishingEventBridge.savedDefeatLocation(captured.audit()));
|
||||
data.put("arena",games.dmg.fantasystick.events.FishingEventBridge.savedArena(captured.audit()));
|
||||
}
|
||||
data.put("source_name",captured.origin());data.put("carrier_character_id",captured.carrier().toString());
|
||||
data.put("catalog_id",captured.catalog().toString());data.put("site_id",captured.site().toString());
|
||||
data.put("quest_id",captured.quest().toString());data.put("encounter_id",captured.source().toString());
|
||||
data.put("redeemer_session_id",context.active().session().toString());
|
||||
data.put("redeemer_location",interactionLocation);
|
||||
data.put("giver_status",giver==null?"menu_origin_unverified":"entered_via_registered_giver");
|
||||
if(giver!=null){data.put("giver_id",giver.id().toString());
|
||||
data.put("giver_location",giverLocation(giver));}
|
||||
var allocations=receipt.allocations();
|
||||
var beneficiaries=new ArrayList<Map<String,String>>();
|
||||
captured.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->{
|
||||
var share=new LinkedHashMap<String,String>();share.put("character_id",entry.getKey().toString());
|
||||
share.put("allocated_xp",entry.getValue().xp().toString());
|
||||
share.put("allocated_coins",entry.getValue().coins().toString());
|
||||
var saved=allocations.stream().filter(value->value.submission().equals(captured.id())
|
||||
&&value.character().equals(entry.getKey())).findFirst().orElse(null);
|
||||
share.put("allocation_status",saved==null?"zero_share_completion":"unlocked");
|
||||
if(saved!=null)share.put("allocation_id",saved.id().toString());
|
||||
beneficiaries.add(share);
|
||||
});
|
||||
data.put("beneficiaries",beneficiaries);
|
||||
fishingEvents.durable("adventure.trophy_redeemed",captured.id(),actor==null
|
||||
?new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null):actor,data);
|
||||
}
|
||||
player.sendMessage("Adventure trophy redeemed; frozen shares are ready for each beneficiary.");
|
||||
}else player.sendMessage("Trophy redemption refused: the receipt is unavailable for this character.");
|
||||
open(player,0,menuGivers.get(player.getUniqueId()));
|
||||
}));
|
||||
}
|
||||
private void claimLegacy(Player player,Context context){adventures.beginLegacyClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"legacy-claim-prepare",failure);schedule(()->player.sendMessage("Retained adventure reward claim failed; earned shares remain."));return;}awardLegacy(player,claim.orElseThrow(),context.record());});}
|
||||
private void awardLegacy(Player player,AdventureQuestState.RewardClaim claim,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(claim.id(),claim.character(),"legacy-claim-award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardLegacy(player,claim,award.record().orElseThrow());return;}if(award.record().isEmpty())return;var saved=award.record().orElseThrow();adventures.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null){report(claim.id(),claim.character(),"legacy-claim-finish",finishFailure);return;}schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied adventure reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" retained adventure XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});});});}
|
||||
private void claim(Player player,Context context){fishing.beginClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"claim-prepare",failure);schedule(()->player.sendMessage("Fishing reward claim failed; earned shares are retained."));return;}awardClaim(player,context.active(),claim.orElseThrow(),context.record());});}
|
||||
private void recoverClaim(FishingState.Claim claim){characters.find(claim.character()).whenComplete((record,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-load",failure);return;}record.ifPresent(value->awardClaim(null,null,claim,value));});}
|
||||
private void awardClaim(Player player,EquipmentSessions.Active active,FishingState.Claim claim,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardClaim(player,active,claim,award.record().orElseThrow());return;}if(award.record().isEmpty())return;var saved=award.record().orElseThrow();fishing.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null){report(claim.id(),claim.character(),"claim-finish",finishFailure);return;}schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied fishing reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" quest XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});});});}
|
||||
private void claim(Player player,Context context){var actor=fishingEvents==null?null:fishingEvents.actor(player);var giver=menuGivers.get(player.getUniqueId());fishing.beginClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"claim-prepare",failure);schedule(()->player.sendMessage("Fishing reward claim failed; earned shares are retained."));return;}awardClaim(player,context.active(),claim.orElseThrow(),context.record(),actor,giver,false);});}
|
||||
private void recoverClaim(FishingState.Claim claim){characters.find(claim.character()).whenComplete((record,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-load",failure);return;}record.ifPresent(value->awardClaim(null,null,claim,value,null,null,true));});}
|
||||
private void awardClaim(Player player,EquipmentSessions.Active active,FishingState.Claim claim,CharacterRecord record,
|
||||
games.dmg.fantasystick.events.FishingEventBridge.Actor actor,AdventureQuestState.Giver giver,boolean recovery){
|
||||
characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{
|
||||
if(failure!=null){report(claim.id(),claim.character(),"claim-award",failure);return;}
|
||||
if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){
|
||||
awardClaim(player,active,claim,award.record().orElseThrow(),actor,giver,recovery);return;
|
||||
}
|
||||
if(award.record().isEmpty())return;
|
||||
var saved=award.record().orElseThrow();
|
||||
var before=fishing.snapshot();
|
||||
var sources=before.allocations().stream().filter(value->claim.allocations().contains(value.id()))
|
||||
.sorted(Comparator.comparing(FishingState.Allocation::id)).toList();
|
||||
fishing.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{
|
||||
if(finishFailure!=null){report(claim.id(),claim.character(),"claim-finish",finishFailure);return;}
|
||||
if(finished&&fishingEvents!=null){
|
||||
var data=new LinkedHashMap<String,Object>();
|
||||
data.put("claim_id",claim.id().toString());data.put("beneficiary_id",claim.character().toString());
|
||||
data.put("requested_xp",claim.xp().toString());data.put("requested_coins",claim.coins().toString());
|
||||
data.put("recovery",recovery);data.put("award_status",award.status().name().toLowerCase(java.util.Locale.ROOT));
|
||||
data.put("revision_before",record.revision().toString());data.put("revision_after",saved.revision().toString());
|
||||
data.put("experience_after",saved.experience().toString());data.put("coins_after",saved.coins().toString());
|
||||
if(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE)data.put("applied_status","previously_applied_receipt");
|
||||
else {
|
||||
var appliedXp=saved.experience().subtract(record.experience()).max(BigInteger.ZERO);
|
||||
var appliedCoins=saved.coins().subtract(record.coins()).max(BigInteger.ZERO);
|
||||
data.put("applied_xp",appliedXp.toString());data.put("lost_xp",claim.xp().subtract(appliedXp).max(BigInteger.ZERO).toString());
|
||||
data.put("applied_coins",appliedCoins.toString());data.put("lost_coins",claim.coins().subtract(appliedCoins).max(BigInteger.ZERO).toString());
|
||||
}
|
||||
data.put("giver_status",recovery?"recovered_without_live_giver":giver==null?"menu_origin_unverified":"entered_via_registered_giver");
|
||||
if(giver!=null){data.put("giver_id",giver.id().toString());data.put("giver_location",giverLocation(giver));}
|
||||
var provenance=new ArrayList<Map<String,String>>();
|
||||
for(var source:sources){var detail=new LinkedHashMap<String,String>();
|
||||
detail.put("allocation_id",source.id().toString());
|
||||
detail.put("source_receipt_id",source.submission().toString());
|
||||
detail.put("source_kind",source.sourceKind());
|
||||
detail.put("catalog_id",source.catalog().toString());detail.put("site_id",source.site().toString());
|
||||
detail.put("allocated_xp",source.xp().toString());detail.put("allocated_coins",source.coins().toString());
|
||||
provenance.add(detail);
|
||||
}
|
||||
data.put("sources",provenance);
|
||||
fishingEvents.durable("adventure.reward_claimed",claim.id(),actor==null
|
||||
?new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null):actor,data);
|
||||
}
|
||||
schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied fishing reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" quest XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});
|
||||
});
|
||||
});
|
||||
}
|
||||
private AdventureQuestState.Quest fishingQuest(Context context){if(adventures.snapshot().reset()!=null)return null;return adventures.activeFor(context.record().id(),context.party()).filter(value->value.fishing()!=null&&eligible(context,value)).orElse(null);}
|
||||
private boolean eligible(Context context,AdventureQuestState.Quest quest){if(quest.unitType()==AdventureQuestState.UnitType.SOLO)return quest.unit().equals(context.record().id());return context.party()!=null&&quest.unit().equals(context.party())&&quest.snapshot().contains(context.record().id())&&context.members().contains(context.record().id());}
|
||||
private List<UUID> beneficiaries(Context context,AdventureQuestState.Quest quest){if(quest.unitType()==AdventureQuestState.UnitType.SOLO)return List.of(context.record().id());var party=parties.partyOfCharacter(context.record().id()).filter(value->value.id().equals(quest.unit())).orElse(null);return party==null?List.of():quest.snapshot().stream().filter(party.characters()::contains).distinct().sorted().toList();}
|
||||
@@ -65,5 +366,5 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
private void report(FishingState.Catch plan,String phase,Throwable failure){report(plan.id(),plan.character(),phase,failure);}
|
||||
private void report(UUID operation,UUID character,String phase,Throwable failure){try{failures.accept(new Failure(operation,character,phase,failure));}catch(RuntimeException ignored){}}
|
||||
private void schedule(Runnable task){if(closed)return;try{nextTick.accept(()->{if(!closed)task.run();});}catch(RuntimeException ignored){}}
|
||||
@Override public void close(){closed=true;if(pulseTask!=null)pulseTask.cancel();for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();menus.clear();}
|
||||
@Override public void close(){closed=true;if(pulseTask!=null)pulseTask.cancel();for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();retainedHooks.clear();menus.clear();menuGivers.clear();}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import java.util.concurrent.*;
|
||||
public final class FishingDirectory implements AutoCloseable {
|
||||
public enum PrepareStatus { APPLIED, EXISTING, DUPLICATE, INSUFFICIENT, COMPLETED }
|
||||
public enum CompleteStatus { APPLIED, DUPLICATE, NOT_FOUND }
|
||||
public record CatchReceipt(CompleteStatus status,BigInteger stockBefore,BigInteger stockAfter) { }
|
||||
public enum CommitStatus { APPLIED, DUPLICATE, INSUFFICIENT, NOT_FOUND }
|
||||
public record SubmissionReceipt(CommitStatus status,BigInteger stockBefore,BigInteger stockAfter) { }
|
||||
public enum TrophyPrepareStatus { APPLIED, EXISTING, REDEEMED }
|
||||
public enum TrophyRedeemStatus { APPLIED, REDEEMED, NOT_FOUND, WRONG_CARRIER }
|
||||
public enum TrophyCommitStatus { APPLIED, DUPLICATE, NOT_FOUND }
|
||||
@@ -27,12 +29,87 @@ public final class FishingDirectory implements AutoCloseable {
|
||||
public List<FishingState.Trophy> trophies(UUID carrier){return snapshot.trophies().stream().filter(value->value.phase()==FishingState.TrophyPhase.EARNED&&value.carrier().equals(carrier)).sorted(Comparator.comparing(FishingState.Trophy::name).thenComparing(FishingState.Trophy::id)).toList();}
|
||||
public CompletableFuture<TrophyPrepareStatus> prepareTrophy(FishingState.Trophy requested){Objects.requireNonNull(requested);return mutate(state->{if(state.trophyReceipts().contains(requested.id()))return result(state,TrophyPrepareStatus.REDEEMED);var existing=state.trophies().stream().filter(value->value.id().equals(requested.id())).findFirst();if(existing.isPresent()){if(!sameTrophyCompletion(existing.orElseThrow(),requested))throw new IllegalStateException("Conflicting adventure trophy");return result(state,TrophyPrepareStatus.EXISTING);}var trophies=new ArrayList<>(state.trophies());trophies.add(requested);return result(copyTrophies(state,trophies,state.trophyReceipts(),state.allocations()),TrophyPrepareStatus.APPLIED);});}
|
||||
public CompletableFuture<TrophyCommitStatus> commitTrophy(UUID id){return mutate(state->{var trophy=state.trophies().stream().filter(value->value.id().equals(id)).findFirst().orElse(null);if(trophy==null)return result(state,TrophyCommitStatus.NOT_FOUND);if(trophy.phase()==FishingState.TrophyPhase.EARNED)return result(state,TrophyCommitStatus.DUPLICATE);var trophies=state.trophies().stream().map(value->value.id().equals(id)?value.earned():value).toList();return result(copyTrophies(state,trophies,state.trophyReceipts(),state.allocations()),TrophyCommitStatus.APPLIED);});}
|
||||
public CompletableFuture<TrophyRedeemStatus> redeemTrophy(UUID id,UUID carrier){Objects.requireNonNull(id);Objects.requireNonNull(carrier);return mutate(state->{if(state.trophyReceipts().contains(id))return result(state,TrophyRedeemStatus.REDEEMED);var trophy=state.trophies().stream().filter(value->value.id().equals(id)).findFirst().orElse(null);if(trophy==null||trophy.phase()!=FishingState.TrophyPhase.EARNED)return result(state,TrophyRedeemStatus.NOT_FOUND);if(!trophy.carrier().equals(carrier))return result(state,TrophyRedeemStatus.WRONG_CARRIER);var allocations=new ArrayList<>(state.allocations());for(var entry:trophy.rewards().entrySet()){var reward=entry.getValue();if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;UUID allocation=trophyAllocationId(trophy.id(),entry.getKey());if(allocations.stream().noneMatch(value->value.id().equals(allocation)))allocations.add(new FishingState.Allocation(allocation,trophy.id(),trophy.catalog(),trophy.site(),entry.getKey(),reward.xp(),reward.coins()));}var receipts=new HashSet<>(state.trophyReceipts());receipts.add(id);return result(copyTrophies(state,state.trophies().stream().filter(value->!value.id().equals(id)).toList(),receipts,allocations),TrophyRedeemStatus.APPLIED);});}
|
||||
public record TrophyRedemption(TrophyRedeemStatus status,List<FishingState.Allocation> allocations){
|
||||
public TrophyRedemption{allocations=List.copyOf(allocations);}
|
||||
}
|
||||
public CompletableFuture<TrophyRedeemStatus> redeemTrophy(UUID id,UUID carrier){
|
||||
return redeemTrophyWithEvidence(id,carrier).thenApply(TrophyRedemption::status);
|
||||
}
|
||||
public CompletableFuture<TrophyRedemption> redeemTrophyWithEvidence(UUID id,UUID carrier){
|
||||
Objects.requireNonNull(id);Objects.requireNonNull(carrier);
|
||||
return mutate(state->{
|
||||
if(state.trophyReceipts().contains(id))
|
||||
return result(state,new TrophyRedemption(TrophyRedeemStatus.REDEEMED,List.of()));
|
||||
var trophy=state.trophies().stream().filter(value->value.id().equals(id)).findFirst().orElse(null);
|
||||
if(trophy==null||trophy.phase()!=FishingState.TrophyPhase.EARNED)
|
||||
return result(state,new TrophyRedemption(TrophyRedeemStatus.NOT_FOUND,List.of()));
|
||||
if(!trophy.carrier().equals(carrier))
|
||||
return result(state,new TrophyRedemption(TrophyRedeemStatus.WRONG_CARRIER,List.of()));
|
||||
var allocations=new ArrayList<>(state.allocations());
|
||||
var created=new ArrayList<FishingState.Allocation>();
|
||||
for(var entry:trophy.rewards().entrySet()){
|
||||
var reward=entry.getValue();
|
||||
if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;
|
||||
UUID allocation=trophyAllocationId(trophy.id(),entry.getKey());
|
||||
var existing=allocations.stream().filter(value->value.id().equals(allocation)).findFirst().orElse(null);
|
||||
if(existing==null){
|
||||
existing=new FishingState.Allocation(allocation,trophy.id(),trophy.catalog(),trophy.site(),
|
||||
entry.getKey(),reward.xp(),reward.coins(),
|
||||
trophy.type().name().toLowerCase(java.util.Locale.ROOT)+"_trophy");
|
||||
allocations.add(existing);
|
||||
}
|
||||
created.add(existing);
|
||||
}
|
||||
var receipts=new HashSet<>(state.trophyReceipts());receipts.add(id);
|
||||
return result(copyTrophies(state,state.trophies().stream().filter(value->!value.id().equals(id))
|
||||
.toList(),receipts,allocations),new TrophyRedemption(TrophyRedeemStatus.APPLIED,created));
|
||||
});
|
||||
}
|
||||
public List<FishingState.Catch> pendingCatches(){return snapshot.catches();}
|
||||
public CompletableFuture<PrepareStatus> prepareCatch(FishingState.Catch requested){Objects.requireNonNull(requested);return mutate(state->{if(state.catchReceipts().contains(requested.id()))return result(state,PrepareStatus.DUPLICATE);var existing=state.catches().stream().filter(value->value.id().equals(requested.id())).findFirst();if(existing.isPresent()){if(!existing.orElseThrow().equals(requested))throw new IllegalStateException("Conflicting fishing catch");return result(state,PrepareStatus.EXISTING);}var catches=new ArrayList<>(state.catches());catches.add(requested);return result(copy(state,state.inventory(),catches,state.catchReceipts(),state.submissions(),state.completions(),state.allocations(),state.claims()),PrepareStatus.APPLIED);});}
|
||||
public CompletableFuture<CompleteStatus> completeCatch(UUID operation){return mutate(state->{if(state.catchReceipts().contains(operation))return result(state,CompleteStatus.DUPLICATE);var caught=state.catches().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(caught==null)return result(state,CompleteStatus.NOT_FOUND);var inventory=new ArrayList<>(state.inventory());if(caught.itemAwarded()){var existing=inventory.stream().filter(value->value.character().equals(caught.character())&&value.item().equals(caught.item())).findFirst().orElse(null);if(existing==null)inventory.add(new FishingState.ItemStack(caught.character(),caught.item(),caught.site(),caught.itemName(),BigInteger.ONE));else{var updated=new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),caught.itemName(),existing.amount().add(BigInteger.ONE));inventory.replaceAll(value->value==existing?updated:value);}}var receipts=new HashSet<>(state.catchReceipts());receipts.add(operation);var catches=state.catches().stream().filter(value->!value.id().equals(operation)).toList();return result(copy(state,inventory,catches,receipts,state.submissions(),state.completions(),state.allocations(),state.claims()),CompleteStatus.APPLIED);});}
|
||||
public CompletableFuture<CompleteStatus> completeCatch(UUID operation){return completeCatchWithEvidence(operation).thenApply(CatchReceipt::status);}
|
||||
public CompletableFuture<CatchReceipt> completeCatchWithEvidence(UUID operation){return mutate(state->{
|
||||
if(state.catchReceipts().contains(operation))return result(state,new CatchReceipt(CompleteStatus.DUPLICATE,null,null));
|
||||
var caught=state.catches().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);
|
||||
if(caught==null)return result(state,new CatchReceipt(CompleteStatus.NOT_FOUND,null,null));
|
||||
var before=quantity(state,caught.character(),caught.item());
|
||||
var inventory=new ArrayList<>(state.inventory());
|
||||
if(caught.itemAwarded()){
|
||||
var existing=inventory.stream().filter(value->value.character().equals(caught.character())&&value.item().equals(caught.item())).findFirst().orElse(null);
|
||||
if(existing==null)inventory.add(new FishingState.ItemStack(caught.character(),caught.item(),caught.site(),caught.itemName(),BigInteger.ONE));
|
||||
else {var updated=new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),caught.itemName(),existing.amount().add(BigInteger.ONE));inventory.replaceAll(value->value==existing?updated:value);}
|
||||
}
|
||||
var receipts=new HashSet<>(state.catchReceipts());receipts.add(operation);
|
||||
var catches=state.catches().stream().filter(value->!value.id().equals(operation)).toList();
|
||||
return result(copy(state,inventory,catches,receipts,state.submissions(),state.completions(),state.allocations(),state.claims()),
|
||||
new CatchReceipt(CompleteStatus.APPLIED,before,before.add(caught.itemAwarded()?BigInteger.ONE:BigInteger.ZERO)));
|
||||
});}
|
||||
public CompletableFuture<PrepareStatus> prepareSubmission(FishingState.Submission requested){Objects.requireNonNull(requested);if(requested.phase()!=FishingState.SubmissionPhase.PREPARED)throw new IllegalArgumentException("Submission must begin prepared");return mutate(state->{var existing=state.submissions().stream().filter(value->value.id().equals(requested.id())).findFirst();if(existing.isPresent()){if(!sameSubmission(existing.orElseThrow(),requested))throw new IllegalStateException("Conflicting fishing submission");return result(state,PrepareStatus.EXISTING);}if(state.submissions().stream().anyMatch(value->value.quest().equals(requested.quest())))return result(state,PrepareStatus.COMPLETED);boolean completed=requested.rewards().keySet().stream().anyMatch(character->state.completions().contains(new FishingState.Completion(requested.catalog(),requested.site(),character)));if(completed)return result(state,PrepareStatus.COMPLETED);if(quantity(state,requested.submitter(),requested.item()).compareTo(requested.quantity())<0)return result(state,PrepareStatus.INSUFFICIENT);var submissions=new ArrayList<>(state.submissions());submissions.add(requested);return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),submissions,state.completions(),state.allocations(),state.claims()),PrepareStatus.APPLIED);});}
|
||||
public CompletableFuture<CommitStatus> commitSubmission(UUID operation){return mutate(state->{var submission=state.submissions().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(submission==null)return result(state,CommitStatus.NOT_FOUND);if(submission.phase()==FishingState.SubmissionPhase.COMMITTED)return result(state,CommitStatus.DUPLICATE);if(quantity(state,submission.submitter(),submission.item()).compareTo(submission.quantity())<0)return result(state,CommitStatus.INSUFFICIENT);var inventory=new ArrayList<>(state.inventory());var existing=inventory.stream().filter(value->value.character().equals(submission.submitter())&&value.item().equals(submission.item())).findFirst().orElseThrow();var remaining=existing.amount().subtract(submission.quantity());if(remaining.signum()==0)inventory.remove(existing);else inventory.replaceAll(value->value==existing?new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),existing.name(),remaining):value);var completions=new HashSet<>(state.completions());var allocations=new ArrayList<>(state.allocations());for(var entry:submission.rewards().entrySet()){completions.add(new FishingState.Completion(submission.catalog(),submission.site(),entry.getKey()));var reward=entry.getValue();if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;UUID id=allocationId(submission.id(),entry.getKey());allocations.add(new FishingState.Allocation(id,submission.id(),submission.catalog(),submission.site(),entry.getKey(),reward.xp(),reward.coins()));}var submissions=state.submissions().stream().map(value->value.id().equals(operation)?value.committed():value).toList();return result(copy(state,inventory,state.catches(),state.catchReceipts(),submissions,completions,allocations,state.claims()),CommitStatus.APPLIED);});}
|
||||
public CompletableFuture<CommitStatus> commitSubmission(UUID operation){return commitSubmissionWithEvidence(operation).thenApply(SubmissionReceipt::status);}
|
||||
public CompletableFuture<SubmissionReceipt> commitSubmissionWithEvidence(UUID operation){return mutate(state->{
|
||||
var submission=state.submissions().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);
|
||||
if(submission==null)return result(state,new SubmissionReceipt(CommitStatus.NOT_FOUND,null,null));
|
||||
if(submission.phase()==FishingState.SubmissionPhase.COMMITTED)
|
||||
return result(state,new SubmissionReceipt(CommitStatus.DUPLICATE,null,null));
|
||||
var before=quantity(state,submission.submitter(),submission.item());
|
||||
if(before.compareTo(submission.quantity())<0)
|
||||
return result(state,new SubmissionReceipt(CommitStatus.INSUFFICIENT,before,before));
|
||||
var inventory=new ArrayList<>(state.inventory());
|
||||
var existing=inventory.stream().filter(value->value.character().equals(submission.submitter())&&value.item().equals(submission.item())).findFirst().orElseThrow();
|
||||
var remaining=before.subtract(submission.quantity());
|
||||
if(remaining.signum()==0)inventory.remove(existing);
|
||||
else inventory.replaceAll(value->value==existing?new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),existing.name(),remaining):value);
|
||||
var completions=new HashSet<>(state.completions());var allocations=new ArrayList<>(state.allocations());
|
||||
for(var entry:submission.rewards().entrySet()){
|
||||
completions.add(new FishingState.Completion(submission.catalog(),submission.site(),entry.getKey()));
|
||||
var reward=entry.getValue();if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;
|
||||
allocations.add(new FishingState.Allocation(allocationId(submission.id(),entry.getKey()),submission.id(),
|
||||
submission.catalog(),submission.site(),entry.getKey(),reward.xp(),reward.coins(),"fishing"));
|
||||
}
|
||||
var submissions=state.submissions().stream().map(value->value.id().equals(operation)?value.committed():value).toList();
|
||||
return result(copy(state,inventory,state.catches(),state.catchReceipts(),submissions,completions,allocations,state.claims()),
|
||||
new SubmissionReceipt(CommitStatus.APPLIED,before,remaining));
|
||||
});}
|
||||
public CompletableFuture<Boolean> finishSubmission(UUID operation){return mutate(state->{var found=state.submissions().stream().filter(value->value.id().equals(operation)&&value.phase()==FishingState.SubmissionPhase.COMMITTED).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),state.submissions().stream().filter(value->!value.id().equals(operation)).toList(),state.completions(),state.allocations(),state.claims()),true);});}
|
||||
public List<FishingState.Submission> pendingSubmissions(){return snapshot.submissions();}
|
||||
public boolean completed(UUID catalog,UUID site,UUID character){return snapshot.completions().contains(new FishingState.Completion(catalog,site,character));}
|
||||
|
||||
@@ -17,24 +17,58 @@ public record FishingState(List<ItemStack> inventory,List<Trophy> trophies,Set<U
|
||||
}
|
||||
public enum TrophyType { CHAMPION, CAMP, DUNGEON }
|
||||
public enum TrophyPhase { PREPARED, EARNED }
|
||||
public record Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards,TrophyPhase phase) {
|
||||
public Trophy {Objects.requireNonNull(id);Objects.requireNonNull(type);Objects.requireNonNull(carrier);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(quest);Objects.requireNonNull(source);name=validName(name);origin=validName(origin);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);if(name.isBlank()||origin.isBlank()||rewards.isEmpty()||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null))throw new IllegalArgumentException("Invalid adventure trophy");}
|
||||
public record Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards,TrophyPhase phase,Map<String,String> audit) {
|
||||
public Trophy {Objects.requireNonNull(id);Objects.requireNonNull(type);Objects.requireNonNull(carrier);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(quest);Objects.requireNonNull(source);name=validName(name);origin=validName(origin);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);audit=Map.copyOf(audit);if(name.isBlank()||origin.isBlank()||rewards.isEmpty()||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null)||audit.size()>32||audit.entrySet().stream().anyMatch(entry->entry.getKey().length()>100||entry.getValue().length()>262144))throw new IllegalArgumentException("Invalid adventure trophy");}
|
||||
public Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards,TrophyPhase phase){this(id,type,carrier,catalog,site,quest,source,name,origin,rewards,phase,Map.of());}
|
||||
public Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards){this(id,type,carrier,catalog,site,quest,source,name,origin,rewards,TrophyPhase.PREPARED);}
|
||||
public Reward total(){return rewards.values().stream().reduce(Reward.empty(),Reward::add);}
|
||||
public Trophy earned(){return phase==TrophyPhase.EARNED?this:new Trophy(id,type,carrier,catalog,site,quest,source,name,origin,rewards,TrophyPhase.EARNED);}
|
||||
public Trophy earned(){return phase==TrophyPhase.EARNED?this:new Trophy(id,type,carrier,catalog,site,quest,source,name,origin,rewards,TrophyPhase.EARNED,audit);}
|
||||
/** Operation-local durable phase revision; not a global character or YAML revision. */
|
||||
public int revision(){return phase==TrophyPhase.PREPARED?1:2;}
|
||||
}
|
||||
public record Catch(UUID id,UUID character,UUID quest,UUID site,UUID item,String itemName,BigInteger xp,boolean itemAwarded) {
|
||||
public Catch {Objects.requireNonNull(id);Objects.requireNonNull(character);Objects.requireNonNull(quest);Objects.requireNonNull(site);Objects.requireNonNull(item);itemName=validName(itemName);Objects.requireNonNull(xp);if(itemName.isBlank()||xp.signum()<0||xp.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException("Invalid fishing catch");}
|
||||
public record Catch(UUID id,UUID character,UUID quest,UUID site,UUID item,String itemName,BigInteger xp,
|
||||
boolean itemAwarded,Map<String,String> audit) {
|
||||
public Catch {
|
||||
Objects.requireNonNull(id);Objects.requireNonNull(character);Objects.requireNonNull(quest);
|
||||
Objects.requireNonNull(site);Objects.requireNonNull(item);itemName=validName(itemName);
|
||||
Objects.requireNonNull(xp);audit=Map.copyOf(audit);
|
||||
if(itemName.isBlank()||xp.signum()<0||xp.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0
|
||||
||audit.size()>32||audit.entrySet().stream().anyMatch(entry->entry.getKey().length()>64
|
||||
||entry.getValue().length()>256))throw new IllegalArgumentException("Invalid fishing catch");
|
||||
}
|
||||
public Catch(UUID id,UUID character,UUID quest,UUID site,UUID item,String itemName,BigInteger xp,
|
||||
boolean itemAwarded){this(id,character,quest,site,item,itemName,xp,itemAwarded,Map.of());}
|
||||
}
|
||||
public enum SubmissionPhase { PREPARED, COMMITTED }
|
||||
public record Submission(UUID id,UUID quest,UUID catalog,UUID site,UUID submitter,UUID item,BigInteger quantity,
|
||||
Map<UUID,Reward> rewards,SubmissionPhase phase) {
|
||||
public Submission {Objects.requireNonNull(id);Objects.requireNonNull(quest);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(submitter);Objects.requireNonNull(item);Objects.requireNonNull(quantity);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);if(quantity.signum()<=0||rewards.isEmpty()||!rewards.containsKey(submitter)||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null))throw new IllegalArgumentException("Invalid fishing submission");}
|
||||
public Submission committed(){return new Submission(id,quest,catalog,site,submitter,item,quantity,rewards,SubmissionPhase.COMMITTED);}
|
||||
Map<UUID,Reward> rewards,SubmissionPhase phase,Map<String,String> audit) {
|
||||
public Submission {
|
||||
Objects.requireNonNull(id);Objects.requireNonNull(quest);Objects.requireNonNull(catalog);
|
||||
Objects.requireNonNull(site);Objects.requireNonNull(submitter);Objects.requireNonNull(item);
|
||||
Objects.requireNonNull(quantity);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);audit=Map.copyOf(audit);
|
||||
if(quantity.signum()<=0||rewards.isEmpty()||!rewards.containsKey(submitter)
|
||||
||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null)
|
||||
||audit.size()>16||audit.entrySet().stream().anyMatch(entry->entry.getKey().length()>64
|
||||
||entry.getValue().length()>8192))throw new IllegalArgumentException("Invalid fishing submission");
|
||||
}
|
||||
public Submission(UUID id,UUID quest,UUID catalog,UUID site,UUID submitter,UUID item,BigInteger quantity,
|
||||
Map<UUID,Reward> rewards,SubmissionPhase phase){
|
||||
this(id,quest,catalog,site,submitter,item,quantity,rewards,phase,Map.of());
|
||||
}
|
||||
public Submission committed(){return new Submission(id,quest,catalog,site,submitter,item,quantity,rewards,SubmissionPhase.COMMITTED,audit);}
|
||||
}
|
||||
public record Completion(UUID catalog,UUID site,UUID character) {public Completion {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);}}
|
||||
public record Allocation(UUID id,UUID submission,UUID catalog,UUID site,UUID character,BigInteger xp,BigInteger coins) {
|
||||
public Allocation {Objects.requireNonNull(id);Objects.requireNonNull(submission);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0||xp.signum()==0&&coins.signum()==0)throw new IllegalArgumentException("Invalid fishing allocation");}
|
||||
public record Allocation(UUID id,UUID submission,UUID catalog,UUID site,UUID character,BigInteger xp,
|
||||
BigInteger coins,String sourceKind) {
|
||||
public Allocation {Objects.requireNonNull(id);Objects.requireNonNull(submission);Objects.requireNonNull(catalog);
|
||||
Objects.requireNonNull(site);Objects.requireNonNull(character);Objects.requireNonNull(xp);
|
||||
Objects.requireNonNull(coins);Objects.requireNonNull(sourceKind);
|
||||
if(sourceKind.isBlank()||xp.signum()<0||coins.signum()<0||xp.signum()==0&&coins.signum()==0)
|
||||
throw new IllegalArgumentException("Invalid fishing allocation");}
|
||||
/** Old receipts lacked a durable source type; do not invent a trophy category on reload. */
|
||||
public Allocation(UUID id,UUID submission,UUID catalog,UUID site,UUID character,BigInteger xp,BigInteger coins){
|
||||
this(id,submission,catalog,site,character,xp,coins,"unavailable_legacy_allocation");
|
||||
}
|
||||
public Reward reward(){return new Reward(xp,coins);}
|
||||
}
|
||||
public record Claim(UUID id,UUID character,Set<UUID> allocations,BigInteger xp,BigInteger coins) {
|
||||
|
||||
@@ -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,105 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
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;
|
||||
|
||||
/** A persisted exact-character grant is the only authorization for this event, not command feedback. */
|
||||
public final class AdminExperienceEventBridge implements AutoCloseable {
|
||||
public record Intent(UUID operation, String actorKind, EventInput.Context actor,
|
||||
UUID targetPlayer, UUID targetSession, UUID targetCharacter,
|
||||
String targetName, EventInput.Location targetLocation) { }
|
||||
private final Clock clock;
|
||||
private final FeatureEventOutbox outbox;
|
||||
|
||||
public AdminExperienceEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, Consumer<Throwable> failures) {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
outbox = new FeatureEventOutbox(data, "admin-experience-event-intents",
|
||||
java.util.Set.of("experience.granted"), Objects.requireNonNull(sink), failures);
|
||||
}
|
||||
|
||||
public Intent capture(CommandSender sender, EquipmentSessions.Active target, CharacterRecord record,
|
||||
Player targetPlayer) {
|
||||
var player = sender instanceof Player value ? value : null;
|
||||
var actorKind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
|
||||
EventInput.Context actor = null;
|
||||
if (player != null) {
|
||||
try { actor = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
|
||||
location(player)); } catch (RuntimeException unavailable) { /* Keep the policy operation; location is optional. */ }
|
||||
}
|
||||
return new Intent(UUID.randomUUID(), actorKind, actor, target.player(), target.session(), record.id(),
|
||||
record.name(), targetPlayer == null ? null : location(targetPlayer));
|
||||
}
|
||||
|
||||
public void granted(Intent intent, CharacterRecord before, CharacterRecord after, BigInteger requested,
|
||||
int levelsGained, boolean feedbackDelivered) {
|
||||
if (intent == null) return;
|
||||
try {
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("operation_id", intent.operation().toString());
|
||||
values.put("actor_kind", intent.actorKind());
|
||||
values.put("target_player_id", intent.targetPlayer().toString());
|
||||
values.put("target_session_id", intent.targetSession().toString());
|
||||
values.put("target_character_id", intent.targetCharacter().toString());
|
||||
values.put("target_character_name", intent.targetName());
|
||||
if (intent.actor() == null || intent.actor().location() == null)
|
||||
values.put("admin_location_status", intent.actor() == null ? "not_applicable" : "unavailable");
|
||||
if (intent.targetLocation() != null) values.put("target_acceptance_location", locationData(intent.targetLocation()));
|
||||
else values.put("target_location_status", "unavailable");
|
||||
values.put("requested_xp", requested.toString());
|
||||
values.put("applied_xp", after.experience().subtract(before.experience()).toString());
|
||||
values.put("before_xp", before.experience().toString());
|
||||
values.put("after_xp", after.experience().toString());
|
||||
values.put("before_level", Integer.toString(before.level()));
|
||||
values.put("after_level", Integer.toString(after.level()));
|
||||
values.put("before_unspent_points", before.unspentPoints().toString());
|
||||
values.put("after_unspent_points", after.unspentPoints().toString());
|
||||
values.put("before_revision", before.revision().toString());
|
||||
values.put("after_revision", after.revision().toString());
|
||||
values.put("levels_gained", Integer.toString(levelsGained));
|
||||
values.put("cap_clamped", after.experience().subtract(before.experience()).compareTo(requested) < 0);
|
||||
values.put("same_session_feedback_delivered", feedbackDelivered);
|
||||
values.put("outcome", "durably_applied");
|
||||
var event = new EventInput(id(intent.operation()), intent.operation(), clock.instant(),
|
||||
"experience.granted", intent.actor(), values);
|
||||
outbox.accepted(event);
|
||||
} catch (RuntimeException ignored) { /* Audit failure cannot replay, redirect or undo a grant. */ }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Player player) {
|
||||
try {
|
||||
Location value = player.getLocation();
|
||||
if (value == null || value.getWorld() == null) return null;
|
||||
var world = value.getWorld();
|
||||
return new EventInput.Location(world.getUID(), world.getName(), value.getX(), value.getY(),
|
||||
value.getZ(), clock.instant(), "exact");
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(EventInput.Location place) {
|
||||
return Map.of("world_id", place.worldId().toString(), "world_name", place.worldName(),
|
||||
"x", place.x(), "y", place.y(), "z", place.z(), "captured_at", place.capturedAt().toString(),
|
||||
"quality", place.quality());
|
||||
}
|
||||
private static UUID id(UUID operation) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:admin-xp:" + operation).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,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,270 @@
|
||||
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 if (value instanceof java.util.List<?> list)
|
||||
fields.setProperty(key, "j" + new com.google.gson.Gson().toJson(list));
|
||||
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;
|
||||
}
|
||||
case 'j' -> {
|
||||
var parsed = com.google.gson.JsonParser.parseString(value.substring(1));
|
||||
if (!parsed.isJsonArray()) throw new IOException("Invalid event collection");
|
||||
yield decodeCollection(parsed, 0);
|
||||
}
|
||||
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 static Object decodeCollection(com.google.gson.JsonElement value, int depth) throws IOException {
|
||||
if (depth > 8 || value.isJsonNull()) throw new IOException("Unsafe event collection");
|
||||
if (value.isJsonArray()) {
|
||||
var list = new java.util.ArrayList<>();
|
||||
for (var item : value.getAsJsonArray()) list.add(decodeCollection(item, depth + 1));
|
||||
return java.util.List.copyOf(list);
|
||||
}
|
||||
if (value.isJsonObject()) {
|
||||
var map = new LinkedHashMap<String, Object>();
|
||||
for (var entry : value.getAsJsonObject().entrySet())
|
||||
map.put(entry.getKey(), decodeCollection(entry.getValue(), depth + 1));
|
||||
return java.util.Map.copyOf(map);
|
||||
}
|
||||
var primitive = value.getAsJsonPrimitive();
|
||||
if (primitive.isBoolean()) return primitive.getAsBoolean();
|
||||
if (primitive.isString()) return primitive.getAsString();
|
||||
if (primitive.isNumber()) {
|
||||
var number = primitive.getAsDouble();
|
||||
if (!Double.isFinite(number)) throw new IOException("Non-finite event collection number");
|
||||
return number;
|
||||
}
|
||||
throw new IOException("Unsupported event collection value");
|
||||
}
|
||||
|
||||
private void publish(EventInput event) {
|
||||
try { sink.apply(event).whenComplete((delivery, failure) -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (delivery == LocalEventLog.Delivery.REJECTED) return;
|
||||
try { if (Files.deleteIfExists(path(event))) forceDirectory(); }
|
||||
catch (IOException error) { report(error); }
|
||||
}); } catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
|
||||
private void forceDirectory() throws IOException {
|
||||
try (var channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); }
|
||||
}
|
||||
|
||||
private void report(Throwable error) {
|
||||
try { failures.accept(error); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
worker.shutdown();
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
if (!worker.awaitTermination(5, TimeUnit.SECONDS)) report(new IOException("Character event intents did not drain"));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt(); report(interrupted);
|
||||
} finally { drained.complete(null); }
|
||||
});
|
||||
}
|
||||
|
||||
CompletionStage<Void> drained() { return drained; }
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.adventure.FishingState;
|
||||
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.List;
|
||||
import java.util.Map;
|
||||
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.command.BlockCommandSender;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Acknowledged fishing outcomes and native fishing attempts, independent of chat feedback. */
|
||||
public final class FishingEventBridge implements AutoCloseable {
|
||||
public record Actor(String kind, EventInput.Context context) { }
|
||||
private static final Set<String> DURABLE = Set.of("adventure.site.registered", "adventure.site.updated", "adventure.site.mode_changed",
|
||||
"adventure.quest.accepted", "config.changed", "fishing.catch_resolved",
|
||||
"adventure.items_submitted", "adventure.reward_claimed",
|
||||
"adventure.trophy_earned", "adventure.trophy_redeemed", "combat.reward_resolved");
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final EquipmentSessions sessions;
|
||||
private final FeatureEventOutbox outbox;
|
||||
|
||||
public FishingEventBridge(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, "fishing-event-intents", DURABLE, sink, failures);
|
||||
}
|
||||
|
||||
public Actor actor(CommandSender sender) {
|
||||
try {
|
||||
if (!(sender instanceof Player player))
|
||||
return new Actor(sender instanceof BlockCommandSender ? "command_block" : "console", null);
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return new Actor("player", 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 site(String type, UUID operation, Actor actor, AdventureQuestState.Site site,
|
||||
Map<String, Object> changed) {
|
||||
if (actor == null || site == null || site.fishing() == null) return;
|
||||
var terms = site.fishing();
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("actor_kind", actor.kind());
|
||||
values.put("site_kind", "fishing");
|
||||
values.put("site_id", site.id().toString());
|
||||
values.put("site_name", site.name());
|
||||
values.put("item_id", terms.itemId().toString());
|
||||
values.put("item_name", terms.itemName());
|
||||
values.put("required", terms.required().toString());
|
||||
values.put("chance_percent", terms.chancePercent().toPlainString());
|
||||
values.put("reward_xp", terms.rewardXp().toString());
|
||||
values.put("reward_coins", terms.rewardCoins().toString());
|
||||
values.put("radius", terms.radius());
|
||||
values.put("mode", site.participationMode().value());
|
||||
values.put("world_id", site.world().toString());
|
||||
values.put("x", site.x()); values.put("y", site.y()); values.put("z", site.z());
|
||||
values.put("changed", changed);
|
||||
durable(type, operation, actor, values);
|
||||
}
|
||||
|
||||
public void accepted(UUID operation, Actor actor, AdventureQuestState.Quest quest) {
|
||||
if (actor == null || quest.fishing() == null) return;
|
||||
var terms = quest.fishing();
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("site_kind", "fishing"); values.put("quest_id", quest.id().toString());
|
||||
values.put("catalog_id", quest.catalog().toString()); values.put("site_id", quest.site().toString());
|
||||
values.put("unit_kind", quest.unitType().name().toLowerCase(java.util.Locale.ROOT));
|
||||
values.put("unit_id", quest.unit().toString());
|
||||
values.put("item_id", terms.itemId().toString()); values.put("item_name", terms.itemName());
|
||||
values.put("required", terms.required().toString());
|
||||
values.put("chance_percent", terms.chancePercent().toPlainString());
|
||||
values.put("radius", terms.radius());
|
||||
values.put("reward_xp", terms.rewardXp().toString());
|
||||
values.put("reward_coins", terms.rewardCoins().toString());
|
||||
values.put("roster", quest.snapshot().stream().sorted().map(UUID::toString).toList());
|
||||
durable("adventure.quest.accepted", operation, actor, values);
|
||||
}
|
||||
|
||||
public void configRead(Actor actor, String key, String effective, String defaultValue) {
|
||||
if (actor == null) return;
|
||||
var data = config(actor, key, defaultValue);
|
||||
data.put("effective", effective); data.put("outcome", "acknowledged_read");
|
||||
transientEvent("config.read", UUID.randomUUID(), actor, data);
|
||||
}
|
||||
|
||||
public void configChanged(Actor actor, UUID operation, String key, String before,
|
||||
String effective, String defaultValue) {
|
||||
if (actor == null) return;
|
||||
var data = config(actor, key, defaultValue);
|
||||
data.put("before", before); data.put("after", effective); data.put("effective", effective);
|
||||
data.put("outcome", "acknowledged_future_casts");
|
||||
durable("config.changed", operation, actor, data);
|
||||
}
|
||||
|
||||
private static LinkedHashMap<String, Object> config(Actor actor, String key, String defaultValue) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", actor.kind()); data.put("key", key); data.put("default", defaultValue);
|
||||
data.put("application_scope", "future_fishing_casts");
|
||||
if (actor.context() == null) data.put("location_status", "not_applicable");
|
||||
else if (actor.context().location() == null) data.put("location_status", "unavailable");
|
||||
return data;
|
||||
}
|
||||
|
||||
public Actor actorFromCatch(FishingState.Catch catchReceipt) {
|
||||
var audit = catchReceipt.audit();
|
||||
try {
|
||||
return new Actor("player", new EventInput.Context(UUID.fromString(audit.get("player_id")),
|
||||
audit.get("player_name"), catchReceipt.character(), audit.get("character_name"),
|
||||
UUID.fromString(audit.get("session_id")), null));
|
||||
} catch (RuntimeException unavailable) { return new Actor("system", null); }
|
||||
}
|
||||
|
||||
/** Reconstruct a combat-trophy outcome solely from the saved receipt, including after a prepared retry. */
|
||||
public void trophyEarned(FishingState.Trophy receipt) {
|
||||
if ((receipt.type() != FishingState.TrophyType.CHAMPION
|
||||
&& receipt.type() != FishingState.TrophyType.CAMP)
|
||||
|| receipt.phase() != FishingState.TrophyPhase.EARNED) return;
|
||||
var audit = receipt.audit();
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("receipt_id", receipt.id().toString());
|
||||
evidence.put("trophy_type", receipt.type().name().toLowerCase(java.util.Locale.ROOT) + "_trophy");
|
||||
evidence.put("trophy_name", receipt.name());
|
||||
evidence.put("source_name", receipt.origin());
|
||||
evidence.put("carrier_character_id", receipt.carrier().toString());
|
||||
evidence.put("carrier_is_beneficiary", receipt.rewards().containsKey(receipt.carrier()));
|
||||
evidence.put("quest_id", receipt.quest().toString());
|
||||
evidence.put("catalog_id", receipt.catalog().toString());
|
||||
evidence.put("site_id", receipt.site().toString());
|
||||
evidence.put("encounter_id", receipt.source().toString());
|
||||
if (receipt.type() == FishingState.TrophyType.CHAMPION) {
|
||||
evidence.put("champion_id", audit.getOrDefault("champion_id", "unavailable_legacy_capture"));
|
||||
evidence.put("defeat_location", savedDefeatLocation(audit));
|
||||
evidence.put("defeated_at", audit.getOrDefault("defeated_at", "unavailable_legacy_capture"));
|
||||
} else {
|
||||
evidence.put("final_enemy_id", audit.getOrDefault("final_enemy_id", "unavailable_legacy_capture"));
|
||||
evidence.put("clear_location", savedDefeatLocation(audit));
|
||||
evidence.put("cleared_at", audit.getOrDefault("defeated_at", "unavailable_legacy_capture"));
|
||||
evidence.put("arena", savedArena(audit));
|
||||
}
|
||||
evidence.put("unit_kind", audit.getOrDefault("unit_kind", "unavailable_legacy_capture"));
|
||||
evidence.put("unit_id", audit.getOrDefault("unit_id", "unavailable_legacy_capture"));
|
||||
var roster = audit.get("accepted_roster");
|
||||
evidence.put("accepted_roster", roster == null || roster.isEmpty()
|
||||
? List.of() : List.of(roster.split(",")));
|
||||
evidence.put("accepted_roster_status", roster == null ? "unavailable_legacy_capture" : "captured");
|
||||
evidence.put("reward_xp_pool", receipt.total().xp().toString());
|
||||
evidence.put("reward_coin_pool", receipt.total().coins().toString());
|
||||
evidence.put("phase", "earned");
|
||||
evidence.put("receipt_revision", Integer.toString(receipt.revision()));
|
||||
var beneficiaries = new java.util.ArrayList<Map<String, String>>();
|
||||
receipt.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
|
||||
var share = new LinkedHashMap<String, String>();
|
||||
share.put("character_id", entry.getKey().toString());
|
||||
share.put("allocated_xp", entry.getValue().xp().toString());
|
||||
share.put("allocated_coins", entry.getValue().coins().toString());
|
||||
share.put("allocation_status", entry.getValue().xp().signum() == 0
|
||||
&& entry.getValue().coins().signum() == 0 ? "zero_share_completion" : "positive");
|
||||
beneficiaries.add(share);
|
||||
});
|
||||
evidence.put("beneficiaries", beneficiaries);
|
||||
durable("adventure.trophy_earned", receipt.id(), new Actor("system", null), evidence);
|
||||
}
|
||||
|
||||
public static Map<String, Object> savedArena(Map<String, String> audit) {
|
||||
try {
|
||||
var shape = audit.get("arena_shape");
|
||||
int x = Integer.parseInt(audit.get("arena_x_blocks"));
|
||||
int z = Integer.parseInt(audit.get("arena_z_blocks"));
|
||||
var expected = "circle".equals(shape)
|
||||
? games.dmg.fantasystick.adventure.AdventureQuestState.Arena.circle(x)
|
||||
: "rectangle".equals(shape)
|
||||
? games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(x, z)
|
||||
: null;
|
||||
if (expected == null || expected.zBlocks() != z) return Map.of("status", "unavailable");
|
||||
return Map.of("shape", shape, "x_blocks", x, "z_blocks", z);
|
||||
} catch (RuntimeException unavailable) { return Map.of("status", "unavailable"); }
|
||||
}
|
||||
|
||||
public static Map<String, Object> savedDefeatLocation(Map<String, String> audit) {
|
||||
try {
|
||||
if (!audit.containsKey("defeat_world_id")) return Map.of("status", "unavailable");
|
||||
var world = UUID.fromString(audit.get("defeat_world_id"));
|
||||
double x = Double.parseDouble(audit.get("defeat_x"));
|
||||
double y = Double.parseDouble(audit.get("defeat_y"));
|
||||
double z = Double.parseDouble(audit.get("defeat_z"));
|
||||
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z))
|
||||
return Map.of("status", "unavailable");
|
||||
return Map.of("world_id", world.toString(),
|
||||
"world_name", audit.getOrDefault("defeat_world_name", "unavailable"),
|
||||
"x", x, "y", y, "z", z);
|
||||
} catch (RuntimeException unavailable) { return Map.of("status", "unavailable"); }
|
||||
}
|
||||
|
||||
public void durable(String type, UUID operation, Actor actor, Map<String, Object> data) {
|
||||
if (actor == null) return;
|
||||
try {
|
||||
String primary = data.get("beneficiaries") instanceof List<?> ? "beneficiaries"
|
||||
: data.get("sources") instanceof List<?> ? "sources"
|
||||
: data.get("roster") instanceof List<?> ? "roster" : null;
|
||||
String secondary = data.get("accepted_roster") instanceof List<?> roster && !roster.isEmpty()
|
||||
? "accepted_roster" : null;
|
||||
if (primary == null) { primary = secondary; secondary = null; }
|
||||
if ((primary == null || ((List<?>)data.get(primary)).size() <= 20)
|
||||
&& (secondary == null || ((List<?>)data.get(secondary)).size() <= 20)) {
|
||||
outbox.accepted(input(type, operation, actor, data));
|
||||
return;
|
||||
}
|
||||
var first = (List<?>)data.get(primary);
|
||||
int firstParts = Math.max(1, (first.size() + 19) / 20);
|
||||
int secondParts = secondary == null ? 0 : (((List<?>)data.get(secondary)).size() + 19) / 20;
|
||||
int total = firstParts + secondParts;
|
||||
for (int index = 0; index < total; index++) {
|
||||
String collection = index < firstParts ? primary : secondary;
|
||||
int collectionPart = index < firstParts ? index : index - firstParts;
|
||||
var entries = (List<?>)data.get(collection);
|
||||
int from = collectionPart * 20, to = Math.min(from + 20, entries.size());
|
||||
var part = new LinkedHashMap<String, Object>(data);
|
||||
for (var key : List.of("beneficiaries", "sources", "roster", "accepted_roster"))
|
||||
if (data.get(key) instanceof List<?>) part.put(key, List.of());
|
||||
part.put(collection, entries.subList(from, to));
|
||||
if (collection.equals("beneficiaries") && data.get("roster") instanceof List<?> roster
|
||||
&& roster.size() == entries.size()) part.put("roster", roster.subList(from, to));
|
||||
part.put("linked_collection", collection);
|
||||
part.put("collection_total", entries.size());
|
||||
part.put("collection_part_index", collectionPart);
|
||||
part.put("part_index", index);
|
||||
part.put("part_count", total);
|
||||
outbox.accepted(input(type, operation, actor, part, index));
|
||||
}
|
||||
} catch (RuntimeException ignored) { /* Audit availability cannot change acknowledged fishing. */ }
|
||||
}
|
||||
|
||||
public void transientEvent(String type, UUID operation, Actor actor, Map<String, Object> data) {
|
||||
if (actor == null) return;
|
||||
try { sink.apply(input(type, operation, actor, data)); }
|
||||
catch (RuntimeException ignored) { /* Observation cannot change native fishing. */ }
|
||||
}
|
||||
|
||||
private EventInput input(String type, UUID operation, Actor actor, Map<String, Object> data) {
|
||||
return input(type, operation, actor, data, -1);
|
||||
}
|
||||
|
||||
private EventInput input(String type, UUID operation, Actor actor, Map<String, Object> data, int part) {
|
||||
var values = new LinkedHashMap<String, Object>(data);
|
||||
values.put("operation_id", operation.toString());
|
||||
var identity = "fantasystick:fishing:" + operation + ':' + type + (part < 0 ? "" : ":part:" + part);
|
||||
return new EventInput(UUID.nameUUIDFromBytes(identity.getBytes(StandardCharsets.UTF_8)), operation,
|
||||
clock.instant(), type, actor.context(), values);
|
||||
}
|
||||
|
||||
private EventInput.Location safeLocation(Player player) {
|
||||
try { return location(player.getLocation()); }
|
||||
catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
if (value == null || value.getWorld() == null) return null;
|
||||
var world = value.getWorld();
|
||||
if (world.getName() == null) return null;
|
||||
return new EventInput.Location(world.getUID(), world.getName(), value.getX(), value.getY(), value.getZ(),
|
||||
clock.instant(), "exact");
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -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,109 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.PartySettings;
|
||||
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 effective party join capacity without reading or mutating protected rosters. */
|
||||
public final class PartyCapacityEventBridge 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 PartyCapacityEventBridge(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, "party-capacity-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(), active == null ? null : active.session(),
|
||||
safeLocation(player)));
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public void read(Actor actor, String effective) {
|
||||
if (actor == null) return;
|
||||
var values = common(actor);
|
||||
values.put("effective", effective);
|
||||
values.put("outcome", "acknowledged_read");
|
||||
var operation = UUID.randomUUID();
|
||||
try { sink.apply(new EventInput(id(operation, "config.read"), operation, clock.instant(),
|
||||
"config.read", actor.context(), values)); }
|
||||
catch (RuntimeException ignored) { /* Observation cannot change party capacity. */ }
|
||||
}
|
||||
|
||||
public void changed(Actor actor, UUID operation, String before, String effective,
|
||||
PartySettings.Update saved) {
|
||||
if (actor == null) return;
|
||||
try {
|
||||
var values = common(actor);
|
||||
values.put("operation_id", operation.toString());
|
||||
values.put("policy_revision", Long.toString(saved.sequence()));
|
||||
values.put("before", before);
|
||||
values.put("after", saved.value());
|
||||
values.put("effective", effective);
|
||||
values.put("existing_rosters_grandfathered", true);
|
||||
values.put("outcome", "acknowledged");
|
||||
outbox.accepted(new EventInput(id(operation, "config.changed"), operation, clock.instant(),
|
||||
"config.changed", actor.context(), values));
|
||||
} catch (RuntimeException ignored) { /* Audit failure cannot roll back a published join limit. */ }
|
||||
}
|
||||
|
||||
private static Map<String, Object> common(Actor actor) {
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("key", PartySettings.KEY);
|
||||
values.put("default", PartySettings.DEFAULT);
|
||||
values.put("actor_kind", actor.kind());
|
||||
values.put("application_scope", "future_join_checks");
|
||||
if (actor.context() == null) values.put("location_status", "not_applicable");
|
||||
else if (actor.context().location() == null) values.put("location_status", "unavailable");
|
||||
return values;
|
||||
}
|
||||
|
||||
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 operation, String type) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:party-capacity:" + operation + ":" + type)
|
||||
.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,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,191 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
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.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;
|
||||
|
||||
/** Recovery observation is never itself a heal; only native and persisted publication owns combat.healed. */
|
||||
public final class RecoveryEventBridge implements AutoCloseable {
|
||||
public record Action(UUID actionId, EventInput.Context context, BigInteger expectedRevision,
|
||||
BigDecimal healthBefore, BigDecimal proposed, BigInteger configuredBase,
|
||||
BigInteger configuredQuickerRegeneration, BigInteger configuredQuickerRegenerationII,
|
||||
Instant lastActivity, Instant eligibleAt,
|
||||
BigDecimal maximumHealth, long pulseOrdinal) { }
|
||||
public record Actor(String kind, EventInput.Context context) { }
|
||||
|
||||
private final Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final EquipmentSessions sessions;
|
||||
|
||||
/** Native/unit-only observer; does not require an export outbox. */
|
||||
public RecoveryEventBridge(Consumer<EventInput> sink, Clock clock) {
|
||||
this.sink = Objects.requireNonNull(sink); this.clock = Objects.requireNonNull(clock);
|
||||
outbox = null; sessions = null;
|
||||
}
|
||||
|
||||
/** Registered plugin path retains acknowledged pulses and policy changes for writer restart. */
|
||||
public RecoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
|
||||
Path data, EquipmentSessions sessions, Consumer<Throwable> failures) {
|
||||
this.sink = event -> sink.apply(event);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
outbox = new FeatureEventOutbox(data, "out-of-combat-recovery-event-intents",
|
||||
java.util.Set.of("combat.healed", "config.changed"), sink, failures);
|
||||
}
|
||||
|
||||
public Actor actor(CommandSender sender) {
|
||||
if (sender instanceof Player player) {
|
||||
try {
|
||||
var active = sessions.active(player.getUniqueId()).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return new Actor("player", new EventInput.Context(player.getUniqueId(), player.getName(),
|
||||
record == null ? null : record.id(), record == null ? null : record.name(),
|
||||
record == null ? null : active.session(), location(player)));
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
return new Actor(sender instanceof BlockCommandSender ? "command_block" : "console", null);
|
||||
}
|
||||
|
||||
public void configRead(Actor actor, String key, String effective) {
|
||||
if (actor == null) return;
|
||||
var values = config(actor, key);
|
||||
values.put("effective", effective);
|
||||
values.put("outcome", "acknowledged_read");
|
||||
var operation = UUID.randomUUID();
|
||||
try { sink.accept(new EventInput(id(operation, "config.read"), operation, clock.instant(),
|
||||
"config.read", actor.context(), values)); }
|
||||
catch (RuntimeException ignored) { /* Audit reads cannot change recovery. */ }
|
||||
}
|
||||
|
||||
public void configChanged(Actor actor, UUID operation, String key, String before, String effective,
|
||||
OutOfCombatRecoverySettings.Update saved) {
|
||||
if (actor == null || outbox == null) return;
|
||||
try {
|
||||
var values = config(actor, key);
|
||||
values.put("operation_id", operation.toString());
|
||||
values.put("policy_revision", Long.toString(saved.sequence()));
|
||||
values.put("before", before);
|
||||
values.put("after", saved.value());
|
||||
values.put("effective", effective);
|
||||
values.put("outcome", "acknowledged_future_evaluations");
|
||||
outbox.accepted(new EventInput(id(operation, "config.changed"), operation, clock.instant(),
|
||||
"config.changed", actor.context(), values));
|
||||
} catch (RuntimeException ignored) { /* An audit cannot reconfigure or reset the damage clock. */ }
|
||||
}
|
||||
|
||||
private static Map<String, Object> config(Actor actor, String key) {
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("key", key);
|
||||
values.put("default", OutOfCombatRecoverySettings.defaultValue(key));
|
||||
values.put("application_scope", "future_recovery_evaluations");
|
||||
values.put("activity_timestamp_retained", true);
|
||||
values.put("actor_kind", actor.kind());
|
||||
if (actor.context() == null) values.put("location_status", "not_applicable");
|
||||
else if (actor.context().location() == null) values.put("location_status", "unavailable");
|
||||
return values;
|
||||
}
|
||||
|
||||
public Action begin(Player player, EquipmentSessions.Active active, CharacterRecord record,
|
||||
double current, double proposed, BigInteger base, BigInteger bonus, BigInteger bonusII,
|
||||
Instant lastActivity, Instant eligibleAt, double maximum, long pulseOrdinal) {
|
||||
var action = new Action(UUID.randomUUID(), context(player, active, record.name()), record.revision(),
|
||||
BigDecimal.valueOf(current), BigDecimal.valueOf(proposed), base, bonus, bonusII, lastActivity,
|
||||
eligibleAt, BigDecimal.valueOf(maximum), pulseOrdinal);
|
||||
emit("action.observed", action, "observed", null, null, null, null, null, null);
|
||||
return action;
|
||||
}
|
||||
|
||||
public void refused(Action action, String reason) {
|
||||
emit("action.resolved", action, "refused", reason, null, null, null, null, null);
|
||||
}
|
||||
public void failed(Action action, String reason) {
|
||||
emit("action.resolved", action, "failed", reason, null, null, null, null, null);
|
||||
}
|
||||
public void healed(Action action, BigDecimal healthAfter, BigDecimal acceptedTotal,
|
||||
BigDecimal baseContribution, BigDecimal quickerContribution, BigDecimal quickerIIContribution) {
|
||||
emit("action.resolved", action, "accepted", "acknowledged", healthAfter, acceptedTotal,
|
||||
baseContribution, quickerContribution, quickerIIContribution);
|
||||
emit("combat.healed", action, "accepted", "acknowledged", healthAfter, acceptedTotal,
|
||||
baseContribution, quickerContribution, quickerIIContribution);
|
||||
}
|
||||
|
||||
private void emit(String type, Action action, String outcome, String reason, BigDecimal healthAfter,
|
||||
BigDecimal acceptedTotal, BigDecimal baseContribution, BigDecimal quickerContribution,
|
||||
BigDecimal quickerIIContribution) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("action_kind", "out_of_combat_recovery");
|
||||
data.put("recovery_kind", "out_of_combat");
|
||||
data.put("healing_source", "out_of_combat_recovery");
|
||||
data.put("cause", "recovery_timer");
|
||||
data.put("pulse_application_id", action.actionId().toString());
|
||||
data.put("pulse_ordinal", Long.toString(action.pulseOrdinal()));
|
||||
data.put("last_activity_at", action.lastActivity().toString());
|
||||
data.put("eligible_at", action.eligibleAt().toString());
|
||||
data.put("maximum_health", action.maximumHealth().toPlainString());
|
||||
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("configured_quicker_regeneration_ii", action.configuredQuickerRegenerationII().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());
|
||||
var passives = new LinkedHashMap<String, String>();
|
||||
if (quickerContribution.signum() > 0) passives.put("quicker_regeneration_i", quickerContribution.toPlainString());
|
||||
if (quickerIIContribution.signum() > 0) passives.put("quicker_regeneration_ii", quickerIIContribution.toPlainString());
|
||||
if (!passives.isEmpty()) data.put("passive_contributions", passives);
|
||||
data.put("native_publication", "acknowledged");
|
||||
}
|
||||
if (reason != null) data.put(type.equals("combat.healed") ? "native_boundary" : "reason", reason);
|
||||
try {
|
||||
var event = new EventInput(id(action.actionId(), type), action.actionId(), clock.instant(),
|
||||
type, action.context(), data);
|
||||
if (type.equals("combat.healed") && outbox != null) outbox.accepted(event);
|
||||
else sink.accept(event);
|
||||
} catch (RuntimeException ignored) { /* No audit failure can heal or replay a pulse. */ }
|
||||
}
|
||||
|
||||
private EventInput.Context context(Player player, EquipmentSessions.Active active, String name) {
|
||||
return new EventInput.Context(player.getUniqueId(), player.getName(), active.character(), name,
|
||||
active.session(), location(player));
|
||||
}
|
||||
private EventInput.Location location(Player player) {
|
||||
try {
|
||||
Location value = player.getLocation();
|
||||
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 static UUID id(UUID action, String type) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:recovery:" + action + ":" + type)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
public void replay() { if (outbox != null) outbox.replay(); }
|
||||
public CompletionStage<Void> drained() {
|
||||
return outbox == null ? java.util.concurrent.CompletableFuture.completedFuture(null) : outbox.drained();
|
||||
}
|
||||
@Override public void close() { if (outbox != null) outbox.close(); }
|
||||
}
|
||||
@@ -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,127 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.RoleplaySettings;
|
||||
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;
|
||||
|
||||
/** A policy publication requests ordinary exits; it never attests their later receipt completion. */
|
||||
public final class RoleplayAvailabilityEventBridge 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 RoleplayAvailabilityEventBridge(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, "roleplay-availability-event-intents",
|
||||
java.util.Set.of("config.changed", "game.availability_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, boolean disabled) {
|
||||
if (actor == null) return;
|
||||
var values = common(actor);
|
||||
values.put("effective", disabled);
|
||||
values.put("available", !disabled);
|
||||
values.put("outcome", "acknowledged_read");
|
||||
var operation = UUID.randomUUID();
|
||||
try { sink.apply(new EventInput(id(operation, "config.read"), operation, clock.instant(),
|
||||
"config.read", actor.context(), values)); }
|
||||
catch (RuntimeException ignored) { /* An observation cannot change availability. */ }
|
||||
}
|
||||
|
||||
public void changed(Actor actor, UUID operation, boolean before, RoleplaySettings.Update saved,
|
||||
EquipmentSessions.DisableSummary exits) {
|
||||
if (actor == null) return;
|
||||
try {
|
||||
var config = common(actor);
|
||||
config.put("operation_id", operation.toString());
|
||||
config.put("policy_revision", Long.toString(saved.sequence()));
|
||||
config.put("before", before);
|
||||
config.put("after", saved.disabled());
|
||||
config.put("effective", saved.disabled());
|
||||
config.put("outcome", "acknowledged");
|
||||
durable(operation, "config.changed", actor.context(), config);
|
||||
|
||||
var availability = common(actor);
|
||||
availability.put("origin_admin_action_id", operation.toString());
|
||||
availability.put("policy_revision", Long.toString(saved.sequence()));
|
||||
availability.put("available_before", !before);
|
||||
availability.put("available_after", !saved.disabled());
|
||||
availability.put("exit_requested_count", exits == null ? 0 : exits.exitRequested());
|
||||
availability.put("exit_request_accepted_count", exits == null ? 0 : exits.exitRequested());
|
||||
availability.put("exit_native_restored_pending_receipt_count",
|
||||
exits == null ? 0 : exits.nativeRestoredPendingReceipt());
|
||||
availability.put("exit_confirmed_count", 0);
|
||||
availability.put("exit_failed_count", exits == null ? 0 : exits.exitFailed());
|
||||
availability.put("exit_pending_count", exits == null ? 0 : exits.exitPending());
|
||||
availability.put("pending_entry_refused_count", exits == null ? 0 : exits.entryRefused());
|
||||
availability.put("receipt_state", "not_yet_observed");
|
||||
availability.put("outcome", "policy_published_exit_requests_only");
|
||||
durable(operation, "game.availability_changed", actor.context(), availability);
|
||||
} catch (RuntimeException ignored) { /* Logging failure cannot reverse policy or reissue exits. */ }
|
||||
}
|
||||
|
||||
private static Map<String, Object> common(Actor actor) {
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("key", RoleplaySettings.KEY);
|
||||
values.put("actor_kind", actor.kind());
|
||||
values.put("application_scope", "global_and_active_sessions");
|
||||
if (actor.context() == null) values.put("location_status", "not_applicable");
|
||||
else if (actor.context().location() == null) values.put("location_status", "unavailable");
|
||||
return values;
|
||||
}
|
||||
|
||||
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 void durable(UUID operation, String type, EventInput.Context actor, Map<String, Object> values) {
|
||||
outbox.accepted(new EventInput(id(operation, type), operation, clock.instant(), type, actor, values));
|
||||
}
|
||||
private static UUID id(UUID operation, String type) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:roleplay-availability:" + operation + ":" + type)
|
||||
.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,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,178 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.SheetResourcePackSettings;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
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;
|
||||
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
|
||||
|
||||
/** Pack events describe native offers and callbacks, never actual client rendering or gameplay authority. */
|
||||
public final class SheetResourcePackEventBridge implements AutoCloseable {
|
||||
public record Actor(String kind, EventInput.Context context) { }
|
||||
public record Request(UUID action, UUID pack, String assetIdentity, String sha1) { }
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final EquipmentSessions sessions;
|
||||
private final FeatureEventOutbox outbox;
|
||||
|
||||
public SheetResourcePackEventBridge(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, "sheet-resource-pack-event-intents",
|
||||
java.util.Set.of("config.changed", "resource_pack.delivery_requested",
|
||||
"resource_pack.delivery_resolved"), 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(), active == null ? null : active.session(),
|
||||
safeLocation(player)));
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public void read(Actor actor, String key, String value) {
|
||||
if (actor == null) return;
|
||||
var values = config(actor, key);
|
||||
values.put("effective", safeValue(key, value));
|
||||
values.put("outcome", "acknowledged_read");
|
||||
var action = UUID.randomUUID();
|
||||
try { sink.apply(new EventInput(id(action, "config.read"), action, clock.instant(),
|
||||
"config.read", actor.context(), values)); }
|
||||
catch (RuntimeException ignored) { /* A read cannot change a delivery. */ }
|
||||
}
|
||||
|
||||
public void changed(Actor actor, UUID action, String key, String before, String effective,
|
||||
SheetResourcePackSettings.Update saved) {
|
||||
if (actor == null) return;
|
||||
try {
|
||||
var values = config(actor, key);
|
||||
values.put("operation_id", action.toString());
|
||||
values.put("policy_revision", Long.toString(saved.sequence()));
|
||||
values.put("before", safeValue(key, before));
|
||||
values.put("after", safeValue(key, key.equals(SheetResourcePackSettings.URL_KEY)
|
||||
? saved.snapshot().url() : saved.snapshot().sha1()));
|
||||
values.put("effective", safeValue(key, effective));
|
||||
values.put("outcome", "acknowledged_before_optional_redelivery");
|
||||
durable(action, "config.changed", actor.context(), values);
|
||||
} catch (RuntimeException ignored) { /* An audit failure cannot reverse a paired setting. */ }
|
||||
}
|
||||
|
||||
public Request requested(Player player, UUID pack, String url, String sha1, String trigger,
|
||||
boolean delivered) {
|
||||
var action = UUID.randomUUID();
|
||||
var request = new Request(action, pack, identity(url), sha1);
|
||||
try {
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("pack_id", pack.toString());
|
||||
values.put("asset_identity", request.assetIdentity());
|
||||
values.put("sha1", sha1);
|
||||
values.put("trigger", trigger);
|
||||
values.put("prompt", "Optional Fantasy Stick first-person sheet presentation");
|
||||
values.put("required", false);
|
||||
values.put("outcome", delivered ? "native_offer_returned" : "native_offer_failed");
|
||||
var context = playerContext(player);
|
||||
values.put("location_quality", context.location() == null ? "unavailable" : context.location().quality());
|
||||
if (context.location() == null) values.put("location_status", "unavailable");
|
||||
durable(action, "resource_pack.delivery_requested", context, values);
|
||||
} catch (RuntimeException ignored) { /* Pack requests and gameplay never depend on the audit writer. */ }
|
||||
return request;
|
||||
}
|
||||
|
||||
public void resolved(Player player, Request request, PlayerResourcePackStatusEvent.Status status) {
|
||||
try {
|
||||
var outcome = status.name().toLowerCase(Locale.ROOT);
|
||||
if (status == PlayerResourcePackStatusEvent.Status.FAILED_RELOAD) outcome = "reload_failed";
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("pack_id", request.pack().toString());
|
||||
values.put("asset_identity", request.assetIdentity());
|
||||
values.put("sha1", request.sha1());
|
||||
values.put("origin_delivery_action_id", request.action().toString());
|
||||
values.put("status", outcome);
|
||||
values.put("reason", "client_reported_status");
|
||||
values.put("callback_time", clock.instant().toString());
|
||||
var context = playerContext(player);
|
||||
values.put("location_quality", context.location() == null ? "unavailable" : context.location().quality());
|
||||
if (context.location() == null) values.put("location_status", "unavailable");
|
||||
durable(request.action(), "resource_pack.delivery_resolved", context, values, status.name());
|
||||
} catch (RuntimeException ignored) { /* A status cannot authorize gameplay. */ }
|
||||
}
|
||||
|
||||
private static Map<String, Object> config(Actor actor, String key) {
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("key", key);
|
||||
values.put("actor_kind", actor.kind());
|
||||
values.put("application_scope", "later_optional_redelivery");
|
||||
if (actor.context() == null) values.put("location_status", "not_applicable");
|
||||
else if (actor.context().location() == null) values.put("location_status", "unavailable");
|
||||
return values;
|
||||
}
|
||||
|
||||
/** A URL's query can contain credentials; only a one-way identity escapes the policy boundary. */
|
||||
public static String safeValue(String key, String value) {
|
||||
return SheetResourcePackSettings.URL_KEY.equals(key) ? identity(value) : value;
|
||||
}
|
||||
|
||||
private static String identity(String url) {
|
||||
try {
|
||||
return "sha256:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(url.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException unavailable) { throw new IllegalStateException(unavailable); }
|
||||
}
|
||||
|
||||
private EventInput.Context playerContext(Player player) {
|
||||
return new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
|
||||
safeLocation(player));
|
||||
}
|
||||
|
||||
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 void durable(UUID action, String type, EventInput.Context context, Map<String, Object> values) {
|
||||
durable(action, type, context, values, type);
|
||||
}
|
||||
private void durable(UUID action, String type, EventInput.Context context, Map<String, Object> values,
|
||||
String discriminator) {
|
||||
outbox.accepted(new EventInput(id(action, discriminator), action, clock.instant(), type, context, values));
|
||||
}
|
||||
private static UUID id(UUID action, String discriminator) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:sheet-pack:" + action + ":" + discriminator)
|
||||
.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,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,9 +244,10 @@ 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
|
||||
CombatRewardRules.KillPosition restricted = managed.isPresent()
|
||||
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
|
||||
UUID targetReward = defeatedCharacter == null ? target.getUniqueId() : defeatedCharacter;
|
||||
String authority=managed.map(value->value.kind()+":"+value.source()).orElse("ordinary");
|
||||
@@ -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) {
|
||||
|
||||
@@ -20,13 +20,13 @@ public final class YamlFishingRepository {
|
||||
var root=map(yaml().load(Files.readString(file,StandardCharsets.UTF_8)));
|
||||
int schema=number(root.get("schema-version")).intValueExact();if(schema<1||schema>2)throw new IllegalArgumentException();
|
||||
var inventory=new ArrayList<FishingState.ItemStack>();for(Object value:list(root.get("inventory"))){var row=map(value);inventory.add(new FishingState.ItemStack(uuid(row,"character"),uuid(row,"item"),uuid(row,"site"),text(row.get("name")),number(row.get("amount"))));}
|
||||
var trophies=new ArrayList<FishingState.Trophy>();if(schema>=2)for(Object value:list(root.get("trophies"))){var row=map(value);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(Object rewardValue:list(row.get("rewards"))){var reward=map(rewardValue);if(rewards.put(uuid(reward,"character"),new FishingState.Reward(number(reward.get("xp")),number(reward.get("coins"))))!=null)throw new IllegalArgumentException();}trophies.add(new FishingState.Trophy(uuid(row,"id"),FishingState.TrophyType.valueOf(text(row.get("type"))),uuid(row,"carrier"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"quest"),uuid(row,"source"),text(row.get("name")),text(row.get("origin")),rewards,FishingState.TrophyPhase.valueOf(text(row.get("phase")))));}
|
||||
var trophies=new ArrayList<FishingState.Trophy>();if(schema>=2)for(Object value:list(root.get("trophies"))){var row=map(value);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(Object rewardValue:list(row.get("rewards"))){var reward=map(rewardValue);if(rewards.put(uuid(reward,"character"),new FishingState.Reward(number(reward.get("xp")),number(reward.get("coins"))))!=null)throw new IllegalArgumentException();}trophies.add(new FishingState.Trophy(uuid(row,"id"),FishingState.TrophyType.valueOf(text(row.get("type"))),uuid(row,"carrier"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"quest"),uuid(row,"source"),text(row.get("name")),text(row.get("origin")),rewards,FishingState.TrophyPhase.valueOf(text(row.get("phase"))),strings(row.get("audit"))));}
|
||||
var trophyReceipts=schema>=2?uuids(root.get("trophy-receipts")):Set.<UUID>of();
|
||||
var catches=new ArrayList<FishingState.Catch>();for(Object value:list(root.get("catches"))){var row=map(value);catches.add(new FishingState.Catch(uuid(row,"id"),uuid(row,"character"),uuid(row,"quest"),uuid(row,"site"),uuid(row,"item"),text(row.get("item-name")),number(row.get("xp")),bool(row.get("item-awarded"))));}
|
||||
var catches=new ArrayList<FishingState.Catch>();for(Object value:list(root.get("catches"))){var row=map(value);catches.add(new FishingState.Catch(uuid(row,"id"),uuid(row,"character"),uuid(row,"quest"),uuid(row,"site"),uuid(row,"item"),text(row.get("item-name")),number(row.get("xp")),bool(row.get("item-awarded")),strings(row.get("audit"))));}
|
||||
var receipts=uuids(root.get("catch-receipts"));
|
||||
var submissions=new ArrayList<FishingState.Submission>();for(Object value:list(root.get("submissions"))){var row=map(value);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(Object rewardValue:list(row.get("rewards"))){var reward=map(rewardValue);if(rewards.put(uuid(reward,"character"),new FishingState.Reward(number(reward.get("xp")),number(reward.get("coins"))))!=null)throw new IllegalArgumentException();}submissions.add(new FishingState.Submission(uuid(row,"id"),uuid(row,"quest"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"submitter"),uuid(row,"item"),number(row.get("quantity")),rewards,FishingState.SubmissionPhase.valueOf(text(row.get("phase")))));}
|
||||
var submissions=new ArrayList<FishingState.Submission>();for(Object value:list(root.get("submissions"))){var row=map(value);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(Object rewardValue:list(row.get("rewards"))){var reward=map(rewardValue);if(rewards.put(uuid(reward,"character"),new FishingState.Reward(number(reward.get("xp")),number(reward.get("coins"))))!=null)throw new IllegalArgumentException();}submissions.add(new FishingState.Submission(uuid(row,"id"),uuid(row,"quest"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"submitter"),uuid(row,"item"),number(row.get("quantity")),rewards,FishingState.SubmissionPhase.valueOf(text(row.get("phase"))),strings(row.get("audit"))));}
|
||||
var completions=new HashSet<FishingState.Completion>();for(Object value:list(root.get("completions"))){var row=map(value);completions.add(new FishingState.Completion(uuid(row,"catalog"),uuid(row,"site"),uuid(row,"character")));}
|
||||
var allocations=new ArrayList<FishingState.Allocation>();for(Object value:list(root.get("allocations"))){var row=map(value);allocations.add(new FishingState.Allocation(uuid(row,"id"),uuid(row,"submission"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"character"),number(row.get("xp")),number(row.get("coins"))));}
|
||||
var allocations=new ArrayList<FishingState.Allocation>();for(Object value:list(root.get("allocations"))){var row=map(value);allocations.add(new FishingState.Allocation(uuid(row,"id"),uuid(row,"submission"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"character"),number(row.get("xp")),number(row.get("coins")),row.get("source-kind") instanceof String kind&&!kind.isBlank()?kind:"unavailable_legacy_allocation"));}
|
||||
var claims=new ArrayList<FishingState.Claim>();for(Object value:list(root.get("claims"))){var row=map(value);claims.add(new FishingState.Claim(uuid(row,"id"),uuid(row,"character"),uuids(row.get("allocations")),number(row.get("xp")),number(row.get("coins"))));}
|
||||
return new FishingState(inventory,trophies,trophyReceipts,catches,receipts,submissions,completions,allocations,claims);
|
||||
}catch(RuntimeException invalid){throw new IOException("Invalid fishing file",invalid);}
|
||||
@@ -34,19 +34,25 @@ public final class YamlFishingRepository {
|
||||
public void save(FishingState state)throws IOException{
|
||||
Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",2);
|
||||
root.put("inventory",state.inventory().stream().sorted(Comparator.comparing(value->value.character().toString()+value.item())).map(value->row("character",value.character(),"item",value.item(),"site",value.site(),"name",value.name(),"amount",value.amount())).toList());
|
||||
root.put("trophies",state.trophies().stream().sorted(Comparator.comparing(FishingState.Trophy::id)).map(value->row("id",value.id(),"type",value.type(),"carrier",value.carrier(),"catalog",value.catalog(),"site",value.site(),"quest",value.quest(),"source",value.source(),"name",value.name(),"origin",value.origin(),"phase",value.phase(),"rewards",value.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->row("character",entry.getKey(),"xp",entry.getValue().xp(),"coins",entry.getValue().coins())).toList())).toList());
|
||||
root.put("trophies",state.trophies().stream().sorted(Comparator.comparing(FishingState.Trophy::id)).map(value->row("id",value.id(),"type",value.type(),"carrier",value.carrier(),"catalog",value.catalog(),"site",value.site(),"quest",value.quest(),"source",value.source(),"name",value.name(),"origin",value.origin(),"phase",value.phase(),"audit",value.audit(),"rewards",value.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->row("character",entry.getKey(),"xp",entry.getValue().xp(),"coins",entry.getValue().coins())).toList())).toList());
|
||||
root.put("trophy-receipts",state.trophyReceipts().stream().sorted().map(UUID::toString).toList());
|
||||
root.put("catches",state.catches().stream().sorted(Comparator.comparing(FishingState.Catch::id)).map(value->row("id",value.id(),"character",value.character(),"quest",value.quest(),"site",value.site(),"item",value.item(),"item-name",value.itemName(),"xp",value.xp(),"item-awarded",value.itemAwarded())).toList());
|
||||
root.put("catches",state.catches().stream().sorted(Comparator.comparing(FishingState.Catch::id)).map(value->row("id",value.id(),"character",value.character(),"quest",value.quest(),"site",value.site(),"item",value.item(),"item-name",value.itemName(),"xp",value.xp(),"item-awarded",value.itemAwarded(),"audit",value.audit())).toList());
|
||||
root.put("catch-receipts",state.catchReceipts().stream().sorted().map(UUID::toString).toList());
|
||||
root.put("submissions",state.submissions().stream().sorted(Comparator.comparing(FishingState.Submission::id)).map(value->row("id",value.id(),"quest",value.quest(),"catalog",value.catalog(),"site",value.site(),"submitter",value.submitter(),"item",value.item(),"quantity",value.quantity(),"phase",value.phase(),"rewards",value.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->row("character",entry.getKey(),"xp",entry.getValue().xp(),"coins",entry.getValue().coins())).toList())).toList());
|
||||
root.put("submissions",state.submissions().stream().sorted(Comparator.comparing(FishingState.Submission::id)).map(value->row("id",value.id(),"quest",value.quest(),"catalog",value.catalog(),"site",value.site(),"submitter",value.submitter(),"item",value.item(),"quantity",value.quantity(),"phase",value.phase(),"audit",value.audit(),"rewards",value.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->row("character",entry.getKey(),"xp",entry.getValue().xp(),"coins",entry.getValue().coins())).toList())).toList());
|
||||
root.put("completions",state.completions().stream().sorted(Comparator.comparing(value->value.catalog().toString()+value.site()+value.character())).map(value->row("catalog",value.catalog(),"site",value.site(),"character",value.character())).toList());
|
||||
root.put("allocations",state.allocations().stream().sorted(Comparator.comparing(FishingState.Allocation::id)).map(value->row("id",value.id(),"submission",value.submission(),"catalog",value.catalog(),"site",value.site(),"character",value.character(),"xp",value.xp(),"coins",value.coins())).toList());
|
||||
root.put("allocations",state.allocations().stream().sorted(Comparator.comparing(FishingState.Allocation::id)).map(value->row("id",value.id(),"submission",value.submission(),"catalog",value.catalog(),"site",value.site(),"character",value.character(),"xp",value.xp(),"coins",value.coins(),"source-kind",value.sourceKind())).toList());
|
||||
root.put("claims",state.claims().stream().sorted(Comparator.comparing(FishingState.Claim::id)).map(value->row("id",value.id(),"character",value.character(),"allocations",value.allocations().stream().sorted().toList(),"xp",value.xp(),"coins",value.coins())).toList());
|
||||
Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"fishing-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic fishing persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}
|
||||
}
|
||||
private static Map<String,Object> row(Object...pairs){var result=new LinkedHashMap<String,Object>();for(int index=0;index<pairs.length;index+=2)result.put((String)pairs[index],encode(pairs[index+1]));return result;}
|
||||
private static Object encode(Object value){if(value instanceof UUID||value instanceof Enum<?>||value instanceof BigInteger)return value.toString();if(value instanceof Collection<?> values)return values.stream().map(YamlFishingRepository::encode).toList();return value;}
|
||||
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(64);options.setCodePointLimit(4_194_304);return new Yaml(new SafeConstructor(options));}
|
||||
private static Map<String,String> strings(Object value){
|
||||
if(value==null)return Map.of();
|
||||
var result=new LinkedHashMap<String,String>();
|
||||
map(value).forEach((key,item)->{if(result.put(text(key),text(item))!=null)throw new IllegalArgumentException();});
|
||||
return result;
|
||||
}
|
||||
private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}
|
||||
private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}
|
||||
private static String text(Object value){if(value instanceof String result)return result;throw new IllegalArgumentException();}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package games.dmg.fantasystick.presentation;
|
||||
|
||||
import games.dmg.fantasystick.persistence.SheetResourcePackSettings;
|
||||
import games.dmg.fantasystick.events.SheetResourcePackEventBridge;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
@@ -9,6 +14,7 @@ import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
|
||||
|
||||
/** Optional cosmetic pack delivery. Status never authorizes or cancels gameplay. */
|
||||
@@ -16,36 +22,71 @@ public final class SheetResourcePack implements Listener, AutoCloseable {
|
||||
public static final UUID ID = UUID.fromString("0d53e627-f13b-5a49-b8d5-882d3f4122bb");
|
||||
private final SheetResourcePackSettings settings;
|
||||
private final BiConsumer<UUID, Throwable> failures;
|
||||
private final Map<UUID, Offer> offers = new HashMap<>();
|
||||
private final Map<UUID, Player> recipients = new HashMap<>();
|
||||
private SheetResourcePackEventBridge events;
|
||||
private boolean closed;
|
||||
|
||||
private record Offer(SheetResourcePackEventBridge.Request request,
|
||||
Set<PlayerResourcePackStatusEvent.Status> statuses) { }
|
||||
|
||||
public SheetResourcePack(SheetResourcePackSettings settings, BiConsumer<UUID, Throwable> failures) {
|
||||
this.settings = Objects.requireNonNull(settings);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public void deliverAll(Collection<? extends Player> players) {
|
||||
if (closed) return;
|
||||
for (var player : players) if (player != null && player.isOnline()) deliver(player);
|
||||
public void attachEvents(SheetResourcePackEventBridge bridge) {
|
||||
if (closed || events != null) throw new IllegalStateException("Sheet pack events unavailable");
|
||||
events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
public void deliver(Player player) {
|
||||
public void deliverAll(Collection<? extends Player> players) { deliverAll(players, "startup"); }
|
||||
|
||||
public void deliverAll(Collection<? extends Player> players, String trigger) {
|
||||
if (closed) return;
|
||||
for (var player : players) if (player != null && player.isOnline()) deliver(player, trigger);
|
||||
}
|
||||
|
||||
public void deliver(Player player) { deliver(player, "explicit"); }
|
||||
|
||||
private void deliver(Player player, String trigger) {
|
||||
if (closed || !player.isOnline()) return;
|
||||
var playerId = player.getUniqueId();
|
||||
var prior = offers.get(playerId);
|
||||
var pack = UUID.randomUUID(); // Native callbacks expose only this ID, not a usable hash.
|
||||
var url = settings.url(); var sha1 = settings.sha1();
|
||||
try {
|
||||
player.addResourcePack(ID, settings.url(), java.util.HexFormat.of().parseHex(settings.sha1()),
|
||||
player.addResourcePack(pack, url, java.util.HexFormat.of().parseHex(sha1),
|
||||
"Optional Fantasy Stick first-person sheet presentation", false);
|
||||
if (events != null) offers.put(playerId, new Offer(
|
||||
events.requested(player, pack, url, sha1, trigger, true), new HashSet<>()));
|
||||
recipients.put(playerId, player);
|
||||
if (prior != null) try { player.removeResourcePack(prior.request().pack()); }
|
||||
catch (RuntimeException failure) { report(playerId, failure); }
|
||||
} catch (RuntimeException failure) {
|
||||
report(player.getUniqueId(), failure);
|
||||
player.sendMessage("Fantasy Stick cosmetic resource pack could not be offered; gameplay is unchanged.");
|
||||
if (events != null) events.requested(player, pack, url, sha1, trigger, false);
|
||||
report(playerId, failure);
|
||||
try { player.sendMessage("Fantasy Stick cosmetic resource pack could not be offered; gameplay is unchanged."); }
|
||||
catch (RuntimeException feedbackFailure) { report(playerId, feedbackFailure); }
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler public void join(PlayerJoinEvent event) { deliver(event.getPlayer()); }
|
||||
@EventHandler public void join(PlayerJoinEvent event) { deliver(event.getPlayer(), "join"); }
|
||||
@EventHandler public void quit(PlayerQuitEvent event) {
|
||||
var id = event.getPlayer().getUniqueId(); offers.remove(id); recipients.remove(id);
|
||||
}
|
||||
|
||||
@EventHandler public void status(PlayerResourcePackStatusEvent event) {
|
||||
if (closed || !ID.equals(event.getID())) return;
|
||||
if (closed) return;
|
||||
var offer = offers.get(event.getPlayer().getUniqueId());
|
||||
if (offer == null || !offer.request().pack().equals(event.getID())) return;
|
||||
if (offer.statuses().add(event.getStatus()) && events != null)
|
||||
events.resolved(event.getPlayer(), offer.request(), event.getStatus());
|
||||
switch (event.getStatus()) {
|
||||
case DECLINED, FAILED_DOWNLOAD, INVALID_URL, FAILED_RELOAD, DISCARDED ->
|
||||
event.getPlayer().sendMessage("Fantasy Stick cosmetic resource pack is unavailable; gameplay is unchanged.");
|
||||
case DECLINED, FAILED_DOWNLOAD, INVALID_URL, FAILED_RELOAD, DISCARDED -> {
|
||||
try { event.getPlayer().sendMessage("Fantasy Stick cosmetic resource pack is unavailable; gameplay is unchanged."); }
|
||||
catch (RuntimeException feedbackFailure) { report(event.getPlayer().getUniqueId(), feedbackFailure); }
|
||||
}
|
||||
default -> { /* Acceptance/loading is cosmetic and needs no gameplay callback. */ }
|
||||
}
|
||||
}
|
||||
@@ -55,5 +96,13 @@ public final class SheetResourcePack implements Listener, AutoCloseable {
|
||||
catch (RuntimeException ignored) { /* Diagnostics cannot make a cosmetic pack mandatory. */ }
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; }
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
offers.forEach((id, offer) -> {
|
||||
var player = recipients.get(id);
|
||||
if (player != null) try { player.removeResourcePack(offer.request().pack()); }
|
||||
catch (RuntimeException failure) { report(id, failure); }
|
||||
});
|
||||
offers.clear(); recipients.clear(); events = null;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.events.AdminExperienceEventBridge;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
@@ -11,6 +12,7 @@ import java.util.function.Function;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.command.CommandSender;
|
||||
|
||||
/** Acknowledged administrator XP mutation captured against one exact active character/session. */
|
||||
public final class AdminExperienceGrant implements AutoCloseable {
|
||||
@@ -27,6 +29,20 @@ public final class AdminExperienceGrant implements AutoCloseable {
|
||||
private final Consumer<Failure> failures;
|
||||
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
|
||||
private volatile boolean closed;
|
||||
private AdminExperienceEventBridge events;
|
||||
private final java.util.concurrent.ConcurrentMap<UUID, Pending> pending = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
private static final class Pending {
|
||||
final AdminExperienceEventBridge.Intent intent;
|
||||
final CharacterRecord captured;
|
||||
final BigInteger amount;
|
||||
CharacterStorage.ExperienceAward award;
|
||||
boolean resolved;
|
||||
boolean emitted;
|
||||
Pending(AdminExperienceEventBridge.Intent intent, CharacterRecord captured, BigInteger amount) {
|
||||
this.intent = intent; this.captured = captured; this.amount = amount;
|
||||
}
|
||||
}
|
||||
|
||||
public AdminExperienceGrant(CharacterStorage characters, ProgressionRules progression, EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Function<UUID, Player> players, Consumer<Failure> failures) {
|
||||
@@ -45,32 +61,59 @@ public final class AdminExperienceGrant implements AutoCloseable {
|
||||
this.particles = Objects.requireNonNull(particles);
|
||||
}
|
||||
|
||||
public void attachEvents(AdminExperienceEventBridge bridge) {
|
||||
if (closed || events != null) throw new IllegalStateException("Experience grant events unavailable");
|
||||
events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
/** Returns false before persistence when the captured active identity is no longer authoritative. */
|
||||
public boolean grant(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
|
||||
Consumer<Result> completion) {
|
||||
return grant(active, captured, amount, null, completion);
|
||||
}
|
||||
|
||||
public boolean grant(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
|
||||
CommandSender administrator, Consumer<Result> completion) {
|
||||
Objects.requireNonNull(active); Objects.requireNonNull(captured);
|
||||
Objects.requireNonNull(amount); Objects.requireNonNull(completion);
|
||||
if (amount.signum() <= 0) throw new IllegalArgumentException("Experience grant must be positive");
|
||||
if (closed || !sessions.current(active)
|
||||
|| sessions.currentRecord(active).filter(captured::equals).isEmpty()) return false;
|
||||
AdminExperienceEventBridge.Intent intent = null;
|
||||
if (events != null && administrator != null) {
|
||||
Player target = null;
|
||||
try { target = players.apply(active.player()); }
|
||||
catch (RuntimeException failure) { report(captured.id(), failure); }
|
||||
try { intent = events.capture(administrator, active, captured, target); }
|
||||
catch (RuntimeException failure) { report(captured.id(), failure); }
|
||||
}
|
||||
var operation = new Pending(intent, captured, amount);
|
||||
if (intent != null) pending.put(intent.operation(), operation);
|
||||
characters.awardExperience(captured.id(), captured.revision(), amount, progression)
|
||||
.whenComplete((award, failure) -> schedule(active, captured, amount, completion, award, failure));
|
||||
.whenComplete((award, failure) -> schedule(active, operation, completion, award, failure));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void schedule(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
|
||||
private void schedule(EquipmentSessions.Active active, Pending operation,
|
||||
Consumer<Result> completion, CharacterStorage.ExperienceAward award, Throwable failure) {
|
||||
if (failure != null) report(captured.id(), failure);
|
||||
if (closed) return;
|
||||
if (failure != null) report(operation.captured.id(), failure);
|
||||
synchronized (operation) { operation.award = award; operation.resolved = true; }
|
||||
if (closed) { publish(operation, false); return; }
|
||||
try {
|
||||
nextTick.accept(() -> complete(active, captured, amount, completion, award, failure));
|
||||
} catch (RuntimeException rejected) { report(captured.id(), rejected); }
|
||||
nextTick.accept(() -> complete(active, operation, completion, award, failure));
|
||||
} catch (RuntimeException rejected) {
|
||||
report(operation.captured.id(), rejected);
|
||||
publish(operation, false);
|
||||
}
|
||||
}
|
||||
|
||||
private void complete(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
|
||||
private void complete(EquipmentSessions.Active active, Pending operation,
|
||||
Consumer<Result> completion, CharacterStorage.ExperienceAward award, Throwable failure) {
|
||||
if (closed) return;
|
||||
if (closed) { publish(operation, false); return; }
|
||||
var captured = operation.captured;
|
||||
var amount = operation.amount;
|
||||
if (failure != null) {
|
||||
publish(operation, false);
|
||||
completion.accept(new Result(Status.FAILED, captured.name(), amount, BigInteger.ZERO,
|
||||
captured.level(), 0));
|
||||
return;
|
||||
@@ -84,6 +127,7 @@ public final class AdminExperienceGrant implements AutoCloseable {
|
||||
};
|
||||
BigInteger applied = status == Status.APPLIED
|
||||
? saved.experience().subtract(captured.experience()) : BigInteger.ZERO;
|
||||
boolean feedbackDelivered = false;
|
||||
if (status == Status.APPLIED && sessions.current(active)) {
|
||||
boolean synchronizedSession = sessions.acknowledgeProgress(active, captured.revision(), saved);
|
||||
if (!synchronizedSession && sessions.current(active)
|
||||
@@ -91,17 +135,34 @@ public final class AdminExperienceGrant implements AutoCloseable {
|
||||
report(captured.id(), new IllegalStateException(
|
||||
"Saved administrator XP could not synchronize with its captured active session"));
|
||||
}
|
||||
if (synchronizedSession && sessions.current(active)) feedback(active, saved, applied, award.levels());
|
||||
if (synchronizedSession && sessions.current(active))
|
||||
feedbackDelivered = feedback(active, saved, applied, award.levels());
|
||||
}
|
||||
publish(operation, feedbackDelivered);
|
||||
completion.accept(new Result(status, captured.name(), amount, applied, saved.level(), award.levels()));
|
||||
}
|
||||
|
||||
private void feedback(EquipmentSessions.Active active, CharacterRecord saved, BigInteger applied, int levels) {
|
||||
private void publish(Pending operation, boolean feedbackDelivered) {
|
||||
synchronized (operation) {
|
||||
if (operation.emitted) return;
|
||||
var award = operation.award;
|
||||
if (!operation.resolved) return; // A later worker completion will observe close and retain the intent.
|
||||
operation.emitted = true;
|
||||
if (award != null && award.status() == CharacterStorage.ExperienceAwardStatus.APPLIED
|
||||
&& award.record().isPresent() && events != null) {
|
||||
events.granted(operation.intent, operation.captured, award.record().orElseThrow(),
|
||||
operation.amount, award.levels(), feedbackDelivered);
|
||||
}
|
||||
}
|
||||
if (operation.intent != null) pending.remove(operation.intent.operation(), operation);
|
||||
}
|
||||
|
||||
private boolean feedback(EquipmentSessions.Active active, CharacterRecord saved, BigInteger applied, int levels) {
|
||||
Player player;
|
||||
try { player = players.apply(active.player()); }
|
||||
catch (RuntimeException failure) { report(saved.id(), failure); return; }
|
||||
catch (RuntimeException failure) { report(saved.id(), failure); return false; }
|
||||
if (player == null || !player.isOnline() || !sessions.current(active)
|
||||
|| sessions.currentRecord(active).filter(saved::equals).isEmpty()) return;
|
||||
|| sessions.currentRecord(active).filter(saved::equals).isEmpty()) return false;
|
||||
try {
|
||||
player.sendMessage("Received " + applied + " fantasy XP from an administrator.");
|
||||
for (int gained = 1; gained <= levels; gained++) {
|
||||
@@ -111,7 +172,8 @@ public final class AdminExperienceGrant implements AutoCloseable {
|
||||
0.3, 0.4, 0.3, 0, new org.bukkit.Particle.DustOptions(Color.BLUE, 1));
|
||||
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
|
||||
}
|
||||
} catch (RuntimeException failure) { report(saved.id(), failure); }
|
||||
return true;
|
||||
} catch (RuntimeException failure) { report(saved.id(), failure); return false; }
|
||||
}
|
||||
|
||||
private void report(UUID character, Throwable failure) {
|
||||
@@ -119,5 +181,8 @@ public final class AdminExperienceGrant implements AutoCloseable {
|
||||
catch (RuntimeException ignored) { /* Diagnostics do not replay a grant. */ }
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; }
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
for (var operation : pending.values()) publish(operation, false);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user