Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f0c18bcf2 | ||
|
|
e54896cb38 | ||
|
|
8b14eddf43 | ||
|
|
de5391e8e4 | ||
|
|
c6252e8805 | ||
|
|
e83c6fbabc | ||
|
|
12cdce1845 | ||
|
|
6552a90a93 | ||
|
|
11d85a0a69 | ||
|
|
0486de9b30 | ||
|
|
6f8e83a2e8 | ||
|
|
65a76b7fc4 | ||
|
|
4984ad26e7 | ||
|
|
6fc8c5d9eb | ||
|
|
f1b4479e83 | ||
|
|
d20c38a216 | ||
|
|
03c121287c | ||
|
|
908b0b02aa | ||
|
|
92d3afc2f0 | ||
|
|
42cf9e160f | ||
|
|
cee4690be9 | ||
|
|
9ed5dea830 | ||
|
|
43734d2634 | ||
|
|
103a3e44b7 | ||
|
|
e04a3152c1 | ||
|
|
9bb56f3a1c | ||
|
|
a57b59f4f1 | ||
|
|
f4db24492a |
No files matched your search
@@ -159,6 +159,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
|
||||
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
|
||||
private games.dmg.fantasystick.passive.HealthSteal healthSteal;
|
||||
private games.dmg.fantasystick.events.HealthStealEventBridge healthStealEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> healthStealAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.PassivePotionEventBridge passivePotionEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> passivePotionAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.passive.SpeedI speedI;
|
||||
private games.dmg.fantasystick.passive.LowLightVision lowLightVision;
|
||||
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
|
||||
@@ -166,6 +170,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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 java.util.concurrent.CompletionStage<Void> rareAuditDrain = 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;
|
||||
@@ -181,7 +186,27 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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 java.util.concurrent.CompletionStage<Void> combatRewardAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.StickKnockbackEventBridge stickKnockbackEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> stickKnockbackAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.CharacterBossEventBridge characterBossEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> characterBossDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.DungeonAuditEventBridge dungeonOwnerEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> dungeonOwnerDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.CampEventBridge campAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> campAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.AdventureDayResetEventBridge adventureDayResetEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> adventureDayResetDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.ChampionEventBridge championAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> championAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.HardMobXpEventBridge hardMobXpAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> hardMobXpAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.TrainingEventBridge trainingAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> trainingAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.ArmorEventBridge armorAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> armorAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> coinAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
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);
|
||||
@@ -644,7 +669,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
armorerController=new games.dmg.fantasystick.shop.FantasyArmorerController(this,armorers,armorShopMenus::open);
|
||||
rareShop=new games.dmg.fantasystick.shop.RareShop(rareMerchants,sessions,rareMarket,rareInventory,nextTick,failure->getLogger().log(Level.WARNING,"Rare Items purchase failed",failure));
|
||||
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);}});
|
||||
var pendingRareCatalogs=new java.util.concurrent.ConcurrentLinkedQueue<games.dmg.fantasystick.shop.RareCatalog>();
|
||||
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;var events=rareEvents;
|
||||
if(events==null)pendingRareCatalogs.add(catalog);else events.catalogRefreshed(catalog);
|
||||
if(catalog.reason()!=games.dmg.fantasystick.shop.RareCatalog.RefreshReason.STARTUP)
|
||||
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,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));
|
||||
@@ -676,6 +706,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
adventureQuestController.registerResetParticipant(dungeonController);
|
||||
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.attachManagedRewardEvidence(dungeonController::killEvidence);
|
||||
combatExperience.attachManagedSettlement(dungeonController::settleKill);
|
||||
combatExperience.attachExcluded(entity->adventureQuestController.encounterOrigin(entity).isPresent());
|
||||
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|
||||
@@ -751,11 +782,60 @@ 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());
|
||||
var completionLog = eventLog;
|
||||
var committedDungeonRuns = dungeons;
|
||||
var completionLocation = getDataFolder().toPath();
|
||||
var inFlightCompletions = new java.util.concurrent.ConcurrentHashMap<java.util.UUID,
|
||||
java.util.concurrent.CompletableFuture<games.dmg.fantasystick.events.LocalEventLog.Delivery>>();
|
||||
java.util.function.Function<games.dmg.fantasystick.events.EventInput,
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.events.LocalEventLog.Delivery>> fishingSink = input -> {
|
||||
if (!"dungeon.completed".equals(input.type())) return completionLog.emit(input);
|
||||
var result = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.events.LocalEventLog.Delivery>();
|
||||
var prior = inFlightCompletions.putIfAbsent(input.id(),result);
|
||||
if (prior != null) return prior;
|
||||
try {
|
||||
var evidence = input.data();
|
||||
var dungeon = java.util.UUID.fromString((String)evidence.get("dungeon_id"));
|
||||
var runGeneration = java.util.UUID.fromString((String)evidence.get("generation_id"));
|
||||
var boss = java.util.UUID.fromString((String)evidence.get("boss_id"));
|
||||
var exported = games.dmg.fantasystick.events.DungeonEventBridge.exported(
|
||||
completionLocation,input.id(),dungeon,runGeneration,boss);
|
||||
var delivery = exported
|
||||
? java.util.concurrent.CompletableFuture.completedFuture(
|
||||
games.dmg.fantasystick.events.LocalEventLog.Delivery.EXPORTED)
|
||||
: completionLog.emit(input);
|
||||
delivery.thenCompose(status -> {
|
||||
if (status == games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED)
|
||||
return java.util.concurrent.CompletableFuture.completedFuture(status);
|
||||
return committedDungeonRuns.acknowledgeCompletion(dungeon,runGeneration,boss)
|
||||
.handle((ack,failure) -> {
|
||||
if (failure != null) {
|
||||
getLogger().log(Level.WARNING,"Dungeon completion publication acknowledgement failed",failure);
|
||||
return games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED;
|
||||
}
|
||||
return status;
|
||||
});
|
||||
}).whenComplete((status,failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING,"Dungeon completion event sink failed",failure);
|
||||
result.complete(failure == null ? status
|
||||
: games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED);
|
||||
inFlightCompletions.remove(input.id(),result);
|
||||
});
|
||||
} catch (java.io.IOException | RuntimeException invalid) {
|
||||
getLogger().log(Level.WARNING,"Dungeon completion export evidence is unavailable",invalid);
|
||||
result.complete(games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED);
|
||||
inFlightCompletions.remove(input.id(),result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
fishingAuditEvents = new games.dmg.fantasystick.events.FishingEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
fishingSink, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Fishing event intent failed", failure));
|
||||
adventureQuestCommands.attachFishingEvents(fishingAuditEvents);
|
||||
adventureQuestController.attachFishingEvents(fishingAuditEvents);
|
||||
var dungeonAuditEvents=new games.dmg.fantasystick.events.DungeonEventBridge(fishingAuditEvents);
|
||||
dungeonCommands.attachEvents(dungeonAuditEvents);
|
||||
dungeonController.attachEvents(dungeonAuditEvents);
|
||||
fishingController.attachFishingEvents(fishingAuditEvents);
|
||||
admin.attachFishingEvents(fishingAuditEvents);
|
||||
preferenceEvents = new games.dmg.fantasystick.events.PreferenceEventBridge(
|
||||
@@ -776,7 +856,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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);
|
||||
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC(),
|
||||
getDataFolder().toPath(), failure->getLogger().log(Level.WARNING,"Rare event intent failed",failure));
|
||||
rareShop.attachEvents(rareEvents);rarePotions.attachEvents(rareEvents);rareReskills.attachEvents(rareEvents);rareMagicCores.attachEvents(rareEvents);admin.attachRareEvents(rareEvents);
|
||||
rareEvents.replay();
|
||||
games.dmg.fantasystick.shop.RareCatalog committedRareCatalog;
|
||||
while((committedRareCatalog=pendingRareCatalogs.poll())!=null)
|
||||
rareEvents.catalogRefreshed(committedRareCatalog);
|
||||
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);
|
||||
@@ -801,7 +887,26 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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);
|
||||
var knockbackAudit = new games.dmg.fantasystick.events.StickKnockbackEventBridge(eventLog::emit,
|
||||
getDataFolder().toPath(), failure -> getLogger().log(Level.WARNING, "Knockback event intent failed", failure));
|
||||
stickKnockbackEvents = knockbackAudit;
|
||||
basicAttack.attachKnockbackEvents(knockbackAudit);
|
||||
getServer().getPluginManager().registerEvents(knockbackAudit, this);
|
||||
var passiveWriter = eventLog;
|
||||
passiveEvents = new games.dmg.fantasystick.events.PassiveEventBridge(input -> {
|
||||
if (games.dmg.fantasystick.passive.StickKnockback.ID.equals(input.data().get("passive_id"))
|
||||
&& input.type().startsWith("passive.")) knockbackAudit.acknowledgedPassive(input);
|
||||
else passiveWriter.emit(input);
|
||||
}, Clock.systemUTC());books.attachPassiveEvents(passiveEvents);admin.attachPassiveEvents(passiveEvents);
|
||||
healthStealEvents = new games.dmg.fantasystick.events.HealthStealEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Health Steal event intent failed", failure));
|
||||
healthSteal.attachEvents(healthStealEvents);
|
||||
passivePotionEvents = new games.dmg.fantasystick.events.PassivePotionEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Passive potion event intent failed", failure));
|
||||
speedI.attachEvents(passivePotionEvents);
|
||||
lowLightVision.attachEvents(passivePotionEvents);
|
||||
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Recovery event intent failed", failure));
|
||||
@@ -821,8 +926,56 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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);
|
||||
campAuditEvents = new games.dmg.fantasystick.events.CampEventBridge(eventLog::emit, getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Camp event intent failed", failure));
|
||||
var dungeonActorCapture = campAuditEvents;
|
||||
dungeonOwnerEvents = new games.dmg.fantasystick.events.DungeonAuditEventBridge(eventLog::emit, getDataFolder().toPath(),
|
||||
sender -> dungeonActorCapture.actor(sender, null), failure -> getLogger().log(Level.WARNING, "Dungeon owner event intent failed", failure));
|
||||
admin.attachDungeonEvents(dungeonOwnerEvents);
|
||||
dungeonCommands.attachOwnerEvents(dungeonOwnerEvents);
|
||||
var characterBossActorCapture = dungeonOwnerEvents;
|
||||
characterBossEvents = new games.dmg.fantasystick.events.CharacterBossEventBridge(eventLog::emit, getDataFolder().toPath(),
|
||||
characterBossActorCapture::actor, failure -> getLogger().log(Level.WARNING, "Character boss event intent failed", failure));
|
||||
dungeonCommands.attachCharacterBossEvents(characterBossEvents);
|
||||
dungeonController.attachCharacterBossEvents(characterBossEvents);
|
||||
dungeonController.attachOwnerEvents(dungeonOwnerEvents);
|
||||
adventureQuestController.attachDungeonEvents(dungeonOwnerEvents, dungeons::find);
|
||||
var observedDungeonDirectory = dungeons;
|
||||
adventureQuests.attachDungeonQuestExpiryEvents(dungeonOwnerEvents::questExpired, id -> observedDungeonDirectory.find(id).isPresent());
|
||||
dungeons.attachRunRemovalEvents(dungeonOwnerEvents::runsRemoved);
|
||||
admin.attachCampEvents(campAuditEvents);
|
||||
adventureQuestCommands.attachCampEvents(campAuditEvents);
|
||||
adventureQuestController.attachCampEvents(campAuditEvents);
|
||||
adventureQuests.attachCatalogEvents(campAuditEvents::catalogPublished);
|
||||
championAuditEvents = new games.dmg.fantasystick.events.ChampionEventBridge(eventLog::emit, getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Champion event intent failed", failure));
|
||||
admin.attachChampionEvents(championAuditEvents);
|
||||
adventureQuestCommands.attachChampionEvents(championAuditEvents);
|
||||
adventureQuestController.attachChampionEvents(championAuditEvents);
|
||||
adventureQuests.attachQuestExpiryEvents(campAuditEvents::questExpired);
|
||||
adventureQuests.attachChampionQuestExpiryEvents(championAuditEvents::questExpired);
|
||||
adventureDayResetEvents = new games.dmg.fantasystick.events.AdventureDayResetEventBridge(eventLog::emit, getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Adventure-day reset event export failed", failure), adventureDayResetDrain);
|
||||
adventureDayResetEvents.recover(adventureQuests.snapshot());
|
||||
adventureQuests.attachDayResetEvents(adventureDayResetEvents::prepared);
|
||||
dungeons.attachResetCleanupEvents(adventureDayResetEvents::participantCleanup);
|
||||
adventureQuestController.attachDayResetEvents(adventureDayResetEvents);
|
||||
trainingAuditEvents = new games.dmg.fantasystick.events.TrainingEventBridge(eventLog::emit, getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Training event intent failed", failure));
|
||||
admin.attachTrainingEvents(trainingAuditEvents);
|
||||
trainingDummyController.attachEvents(trainingAuditEvents);
|
||||
armorAuditEvents = new games.dmg.fantasystick.events.ArmorEventBridge(eventLog::emit, getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Armor event intent failed", failure));
|
||||
if (armorMarket != null) armorMarket.attachEvents(armorAuditEvents);
|
||||
admin.attachArmorEvents(armorAuditEvents);
|
||||
hardMobXpAuditEvents = new games.dmg.fantasystick.events.HardMobXpEventBridge(eventLog::emit, getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Hard-mob XP event intent failed", failure));
|
||||
admin.attachHardMobXpEvents(hardMobXpAuditEvents);
|
||||
if (armorShop != null) armorShop.attachEvents(armorAuditEvents);
|
||||
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC(),getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING,"Combat reward event intent failed",failure));combatExperience.attachEvents(combatRewardEvents);
|
||||
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC(),getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING,"Coin event intent failed",failure));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(),
|
||||
@@ -987,6 +1140,71 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous admin experience intent drain failed", failure);
|
||||
if (replayGrantEvents != null) replayGrantEvents.replay();
|
||||
});
|
||||
var replayPassivePotionEvents = passivePotionEvents;
|
||||
passivePotionAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous passive potion event drain failed", failure);
|
||||
if (replayPassivePotionEvents != null) replayPassivePotionEvents.replay();
|
||||
});
|
||||
var replayKnockbackEvents = stickKnockbackEvents;
|
||||
stickKnockbackAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous Knockback event drain failed", failure);
|
||||
if (replayKnockbackEvents != null) replayKnockbackEvents.replay();
|
||||
});
|
||||
var replayHardMobXpEvents = hardMobXpAuditEvents;
|
||||
hardMobXpAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous hard-mob XP event drain failed", failure);
|
||||
if (replayHardMobXpEvents != null) replayHardMobXpEvents.replay();
|
||||
});
|
||||
var replayCharacterBossEvents = characterBossEvents;
|
||||
characterBossDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous character boss event drain failed", failure);
|
||||
if (replayCharacterBossEvents != null) replayCharacterBossEvents.replay();
|
||||
});
|
||||
var replayDungeonOwnerEvents = dungeonOwnerEvents;
|
||||
dungeonOwnerDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous dungeon owner event drain failed", failure);
|
||||
if (replayDungeonOwnerEvents != null) replayDungeonOwnerEvents.replay();
|
||||
});
|
||||
var replayCampEvents = campAuditEvents;
|
||||
campAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous camp event drain failed", failure);
|
||||
if (replayCampEvents != null) replayCampEvents.replay();
|
||||
});
|
||||
var replayChampionEvents = championAuditEvents;
|
||||
championAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous champion event drain failed", failure);
|
||||
if (replayChampionEvents != null) replayChampionEvents.replay();
|
||||
});
|
||||
var replayDayResetEvents = adventureDayResetEvents;
|
||||
adventureDayResetDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous adventure-day reset event drain failed", failure);
|
||||
if (replayDayResetEvents != null) replayDayResetEvents.replay();
|
||||
});
|
||||
var replayTrainingEvents = trainingAuditEvents;
|
||||
trainingAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous training event drain failed", failure);
|
||||
if (replayTrainingEvents != null) replayTrainingEvents.replay();
|
||||
});
|
||||
var replayArmorEvents = armorAuditEvents;
|
||||
armorAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous armor event drain failed", failure);
|
||||
if (replayArmorEvents != null) replayArmorEvents.replay();
|
||||
});
|
||||
var replayCoinEvents = coinEvents;
|
||||
coinAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous coin event drain failed", failure);
|
||||
if (replayCoinEvents != null) replayCoinEvents.replay();
|
||||
});
|
||||
var replayCombatRewardEvents = combatRewardEvents;
|
||||
combatRewardAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous combat reward event drain failed", failure);
|
||||
if (replayCombatRewardEvents != null) replayCombatRewardEvents.replay();
|
||||
});
|
||||
var replayHealthStealEvents = healthStealEvents;
|
||||
healthStealAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous Health Steal event drain failed", failure);
|
||||
if (replayHealthStealEvents != null) replayHealthStealEvents.replay();
|
||||
});
|
||||
var replayRecoveryEvents = recoveryEvents;
|
||||
recoveryAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous recovery event intent drain failed", failure);
|
||||
@@ -1026,6 +1244,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
fishingAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous fishing event intent drain failed", failure);
|
||||
if (replayFishingEvents != null) replayFishingEvents.replay();
|
||||
if (dungeonController != null) dungeonController.replayCompletions();
|
||||
});
|
||||
recipeEvents.replay(); // Serial worker replays old intents before new discoveries are captured.
|
||||
}
|
||||
@@ -1066,6 +1285,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingEvents = sessionEvents; sessionEvents = null;
|
||||
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
|
||||
var closingFishingAuditEvents = fishingAuditEvents; fishingAuditEvents = null;
|
||||
var closingRareEvents = rareEvents; rareEvents = null;
|
||||
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
|
||||
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
|
||||
var closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
|
||||
@@ -1073,12 +1293,25 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
|
||||
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
|
||||
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
|
||||
var closingKnockbackEvents = stickKnockbackEvents; stickKnockbackEvents = null;
|
||||
var closingHardMobXpEvents = hardMobXpAuditEvents; hardMobXpAuditEvents = null;
|
||||
var closingCharacterBossEvents = characterBossEvents; characterBossEvents = null;
|
||||
var closingDungeonOwnerEvents = dungeonOwnerEvents; dungeonOwnerEvents = null;
|
||||
var closingCampEvents = campAuditEvents; campAuditEvents = null;
|
||||
var closingChampionEvents = championAuditEvents; championAuditEvents = null;
|
||||
var closingDayResetEvents = adventureDayResetEvents; adventureDayResetEvents = null;
|
||||
var closingTrainingEvents = trainingAuditEvents; trainingAuditEvents = null;
|
||||
var closingArmorEvents = armorAuditEvents; armorAuditEvents = null;
|
||||
var closingCoinEvents = coinEvents;
|
||||
var closingCombatRewardEvents = combatRewardEvents;
|
||||
var closingHealthStealEvents = healthStealEvents; healthStealEvents = null;
|
||||
var closingPassivePotionEvents = passivePotionEvents; passivePotionEvents = 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 closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = 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;
|
||||
@@ -1191,7 +1424,6 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
|
||||
if (closingFeedbackCommand != null) cleanup("feedback command", closingFeedbackCommand::close);
|
||||
if (closingParticlesCommand != null) cleanup("particles command", closingParticlesCommand::close);
|
||||
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);
|
||||
@@ -1435,6 +1667,118 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingGrantEvents.drained());
|
||||
});
|
||||
if (closingPassivePotionEvents != null) cleanup("passive potion event intent drain", () -> {
|
||||
closingPassivePotionEvents.close();
|
||||
passivePotionAuditDrain = closingPassivePotionEvents.drained();
|
||||
});
|
||||
if (closingHardMobXpEvents != null) cleanup("hard-mob XP event intent drain", () -> {
|
||||
var settingsDrained = closingHardMobXpSettings == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingHardMobXpSettings.drained();
|
||||
hardMobXpAuditDrain = settingsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Hard-mob XP settings drain failed", failure);
|
||||
closingHardMobXpEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingHardMobXpEvents.drained());
|
||||
});
|
||||
if (closingCharacterBossEvents != null) cleanup("character boss event intent drain", () -> {
|
||||
var dungeonDrained = closingDungeons == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingDungeons.drained();
|
||||
var charactersDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
characterBossDrain = java.util.concurrent.CompletableFuture.allOf(dungeonDrained.toCompletableFuture(), charactersDrained.toCompletableFuture())
|
||||
.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character boss authority drain failed", failure);
|
||||
closingCharacterBossEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingCharacterBossEvents.drained());
|
||||
});
|
||||
if (closingDungeonOwnerEvents != null) cleanup("dungeon owner event intent drain", () -> {
|
||||
var dungeonDrained = closingDungeons == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingDungeons.drained();
|
||||
var questsDrained = closingAdventureQuests == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingAdventureQuests.drained();
|
||||
var authorityDrained = java.util.concurrent.CompletableFuture.allOf(dungeonDrained.toCompletableFuture(),
|
||||
questsDrained.toCompletableFuture(), configurationDrain.toCompletableFuture());
|
||||
dungeonOwnerDrain = authorityDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Dungeon owner authority drain failed", failure);
|
||||
closingDungeonOwnerEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingDungeonOwnerEvents.drained());
|
||||
});
|
||||
if (closingCampEvents != null) cleanup("camp event intent drain", () -> {
|
||||
var authorityDrained = closingAdventureQuests == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingAdventureQuests.drained();
|
||||
campAuditDrain = java.util.concurrent.CompletableFuture.allOf(authorityDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
|
||||
.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Camp authority drain failed", failure);
|
||||
closingCampEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingCampEvents.drained());
|
||||
});
|
||||
if (closingChampionEvents != null) cleanup("champion event intent drain", () -> {
|
||||
var authorityDrained = closingAdventureQuests == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingAdventureQuests.drained();
|
||||
championAuditDrain = java.util.concurrent.CompletableFuture.allOf(authorityDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
|
||||
.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Champion authority drain failed", failure);
|
||||
closingChampionEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingChampionEvents.drained());
|
||||
});
|
||||
if (closingDayResetEvents != null) cleanup("adventure-day reset event intent drain", () -> {
|
||||
var authorityDrained = closingAdventureQuests == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingAdventureQuests.drained();
|
||||
adventureDayResetDrain = authorityDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Adventure-day reset authority drain failed", failure);
|
||||
closingDayResetEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingDayResetEvents.drained());
|
||||
});
|
||||
if (closingTrainingEvents != null) cleanup("training event intent drain", () -> {
|
||||
var registrationsDrained = closingTrainingDummies == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingTrainingDummies.drained();
|
||||
trainingAuditDrain = registrationsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Training registration drain failed", failure);
|
||||
closingTrainingEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingTrainingEvents.drained());
|
||||
});
|
||||
if (closingArmorEvents != null) cleanup("armor event intent drain", () -> {
|
||||
var marketDrained = closingArmorMarket == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingArmorMarket.drained();
|
||||
var registrationsDrained = closingArmorers == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingArmorers.drained();
|
||||
armorAuditDrain = java.util.concurrent.CompletableFuture.allOf(marketDrained.toCompletableFuture(),
|
||||
registrationsDrained.toCompletableFuture()).handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Armor authority drain failed", failure);
|
||||
closingArmorEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingArmorEvents.drained());
|
||||
});
|
||||
if (closingCoinEvents != null) cleanup("coin event intent drain", () -> {
|
||||
var storageDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
coinAuditDrain = storageDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character storage drain failed", failure);
|
||||
closingCoinEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingCoinEvents.drained());
|
||||
});
|
||||
if (closingCombatRewardEvents != null) cleanup("combat reward event intent drain", () -> {
|
||||
var storageDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
combatRewardAuditDrain = storageDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character storage drain failed", failure);
|
||||
closingCombatRewardEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingCombatRewardEvents.drained());
|
||||
});
|
||||
if (closingKnockbackEvents != null) cleanup("Knockback event intent drain", () -> {
|
||||
var storageDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
stickKnockbackAuditDrain = storageDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character storage drain failed", failure);
|
||||
closingKnockbackEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingKnockbackEvents.drained());
|
||||
});
|
||||
if (closingHealthStealEvents != null) cleanup("Health Steal event intent drain", () -> {
|
||||
var storageDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
healthStealAuditDrain = storageDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character storage drain failed", failure);
|
||||
closingHealthStealEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingHealthStealEvents.drained());
|
||||
});
|
||||
if (closingRecoveryEvents != null) cleanup("recovery event intent drain", () -> {
|
||||
var settingsDrained = closingOutOfCombatRecoverySettings == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
|
||||
@@ -1499,7 +1843,21 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingFishingAuditEvents.drained());
|
||||
});
|
||||
if (closingRareEvents != null) cleanup("rare event intent drain", () -> {
|
||||
var marketDrained = closingRareMarket == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingRareMarket.drained();
|
||||
var inventoryDrained = closingRareInventory == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingRareInventory.drained();
|
||||
rareAuditDrain = java.util.concurrent.CompletableFuture.allOf(marketDrained.toCompletableFuture(),
|
||||
inventoryDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
|
||||
.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Rare owner drain failed", failure);
|
||||
closingRareEvents.close();return null;
|
||||
}).thenCompose(ignored -> closingRareEvents.drained());
|
||||
});
|
||||
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
|
||||
// Writer shutdown can still acknowledge already accepted preference exports.
|
||||
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
|
||||
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
|
||||
unregister(closingModes);
|
||||
unregister(closingChat);
|
||||
@@ -1531,6 +1889,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingAdminRevival);
|
||||
unregister(closingCharacterRecovery);
|
||||
unregister(closingBasicAttack);
|
||||
unregister(closingKnockbackEvents);
|
||||
unregister(closingNutrition);
|
||||
unregister(closingOutOfCombatRecovery);
|
||||
unregister(closingBooks);
|
||||
|
||||
@@ -435,7 +435,14 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
@Override public void damage(org.bukkit.entity.LivingEntity target, double amount,
|
||||
org.bukkit.damage.DamageSource source) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(source);
|
||||
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(amount, source));
|
||||
games.dmg.fantasystick.training.TrainingDamage.Source metadata = null;
|
||||
try {
|
||||
var record = sessions.currentRecord(input.session()).orElse(null);
|
||||
if (record != null) metadata = new games.dmg.fantasystick.training.TrainingDamage.Source(
|
||||
audit == null ? UUID.randomUUID() : audit.actionId(), input.session().player(), record.id(), record.name(),
|
||||
input.session().session(), definition.name(), definition.id());
|
||||
} catch (RuntimeException ignored) { /* Unavailable audit metadata cannot change the existing effect. */ }
|
||||
var measured = trainingDamage.apply(input.player(), target, metadata, () -> target.damage(amount, source));
|
||||
measured.ifPresent(value -> report(AbilityReport.Type.DAMAGE, target, value));
|
||||
}
|
||||
@Override public void report(AbilityReport.Type type, Entity target, java.math.BigDecimal amount) {
|
||||
|
||||
@@ -14,9 +14,13 @@ public final class AdventureQuestCommands {
|
||||
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;}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);}}
|
||||
private games.dmg.fantasystick.events.CampEventBridge campEvents;
|
||||
public void attachCampEvents(games.dmg.fantasystick.events.CampEventBridge value){campEvents=Objects.requireNonNull(value);}
|
||||
private games.dmg.fantasystick.events.ChampionEventBridge championEvents;
|
||||
public void attachChampionEvents(games.dmg.fantasystick.events.ChampionEventBridge value){championEvents=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();var campAudit=campEvents;var actor=campAudit==null?null:campAudit.actor(sender,null);directory.rerollToday(expected,actor).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;var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,location);UUID operation=UUID.randomUUID();directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena(),mode).whenComplete((value,failure)->{try{if(failure==null){if(campAudit!=null)campAudit.siteRegistered(operation,campActor,value,controller.arena(value));if(championEvents!=null)championEvents.siteRegistered(operation,campActor,value,controller.arena(value));}}catch(RuntimeException ignored){}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);var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);UUID operation=UUID.randomUUID();directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->{try{if(failure==null&&result.status()==AdventureQuestDirectory.ModeStatus.APPLIED){if(campAudit!=null)campAudit.siteModeChanged(operation,campActor,current,result.site().orElseThrow(),controller.arena(result.site().orElseThrow()));if(championEvents!=null)championEvents.siteModeChanged(operation,campActor,current,result.site().orElseThrow(),controller.arena(result.site().orElseThrow()));}}catch(RuntimeException ignored){}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;}var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);UUID operation=UUID.randomUUID();directory.removeSite(id).whenComplete((removed,failure)->{try{if(failure==null&&removed.isPresent()){if(campAudit!=null)camLine truncated
|
||||
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();var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,location);UUID operation=UUID.randomUUID();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 observed=controller.observedGiver(value);boolean nativeReturned=false;try{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())));nativeReturned=true;}finally{auditGiver(campAudit,"adventure.quest_giver.placed",operation,campActor,value,observed,nativeReturned);}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;}var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);UUID operation=UUID.randomUUID();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{var value=removed.orElseThrow();var observed=controller.observedGiver(value);boolean nativeReturned=false;try{controller.removeGiverLoaded(id);nativeReturned=true;}finally{auditGiver(campAudit,"adventure.quest_giver.removed",operation,campActor,value,observed,nativeReturned);}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();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);}}
|
||||
@@ -31,6 +35,12 @@ public final class AdventureQuestCommands {
|
||||
private Optional<AdventureQuestState.Site> site(UUID id){return directory.findSite(id).filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON);}
|
||||
private static AdventureAdminTeleport.Target target(AdventureQuestState.Site site){return new AdventureAdminTeleport.Target(site.id(),site.displayName(),switch(site.type()){case CAMP->"monster camp";case CHAMPION->"champion";case FISHING->"fishing site";case DUNGEON->"dungeon entrance";},site.world(),site.x(),site.y(),site.z(),0,0,true);}
|
||||
private boolean authorized(CommandSender sender){if(sender.hasPermission(PERMISSION))return true;sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");return false;}
|
||||
private void auditGiver(games.dmg.fantasystick.events.CampEventBridge bridge,String type,UUID operation,
|
||||
games.dmg.fantasystick.events.CampEventBridge.Actor actor,AdventureQuestState.Giver giver,
|
||||
AdventureQuestController.ObservedGiver before,boolean nativeReturned){
|
||||
try{if(bridge!=null)bridge.giverMutation(type,operation,actor,giver,before,controller.observedGiver(giver),nativeReturned);}
|
||||
catch(RuntimeException ignored){}
|
||||
}
|
||||
private void respond(CommandSender sender,Runnable response){try{nextTick.accept(()->{if(sender.hasPermission(PERMISSION)&&(!(sender instanceof Player player)||player.isOnline()))response.run();});}catch(RuntimeException ignored){}}
|
||||
private record Placement(String name,AdventureQuestState.Arena arena){}
|
||||
private static Placement placement(String[] args,int start){if(args.length==start)return new Placement("",null);if(Arrays.stream(args,start,args.length).anyMatch(value->value.startsWith("--")))return null;Integer first=whole(args[start]);if(first==null)return looksNumeric(args[start])||args[start].startsWith("--")?null:new Placement(join(args,start),null);if(args.length>start+1){Integer second=whole(args[start+1]);if(second!=null)return first>=8&&first<=128&&second>=8&&second<=128?new Placement(join(args,start+2),AdventureQuestState.Arena.rectangle(first,second)):null;if(looksNumeric(args[start+1]))return null;}return first>=4&&first<=64?new Placement(join(args,start+1),AdventureQuestState.Arena.circle(first)):null;}
|
||||
|
||||
@@ -44,7 +44,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
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;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;
|
||||
private static final class Attempt {AdventureQuestState.Site auditSite;Map<String,Object> auditActivation=Map.of("capture_quality","unavailable");Map<String,Object> auditConfiguration=Map.of();List<org.bukkit.entity.EntityType> auditRoster=List.of();final Map<UUID,String> auditEntities=new LinkedHashMap<>();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());}
|
||||
@@ -74,33 +74,69 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
?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 record ObservedGiver(java.util.List<UUID> ids,String chunkStatus,String quality) {
|
||||
public ObservedGiver{ids=java.util.List.copyOf(ids);}
|
||||
}
|
||||
/** Read only currently loaded native entities; optional audit getters never force a load or authorize reconciliation. */
|
||||
public ObservedGiver observedGiver(AdventureQuestState.Giver value){
|
||||
try{
|
||||
var world=plugin.getServer().getWorld(value.world());
|
||||
if(world==null)return new ObservedGiver(java.util.List.of(),"world_unavailable","unavailable");
|
||||
if(!world.isChunkLoaded(section(value.x()),section(value.z())))return new ObservedGiver(java.util.List.of(),"unloaded","not_loaded");
|
||||
var entities=world.getEntities();
|
||||
if(entities==null)return new ObservedGiver(java.util.List.of(),"loaded","unavailable");
|
||||
var ids=new java.util.ArrayList<UUID>();boolean complete=true;
|
||||
for(var entity:entities){
|
||||
try{if(giver(entity)&&value.id().equals(id(entity,idKey))&&entity.isValid()){
|
||||
var nativeId=entity.getUniqueId();if(nativeId==null)complete=false;else ids.add(nativeId);
|
||||
}}catch(RuntimeException unavailable){complete=false;}
|
||||
}
|
||||
return new ObservedGiver(ids.stream().sorted().toList(),"loaded",complete?"native":"partial");
|
||||
}catch(RuntimeException unavailable){return new ObservedGiver(java.util.List.of(),"unavailable","unavailable");}
|
||||
}
|
||||
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 void removeSiteLoaded(UUID id){var attempt=attempts.remove(id);if(attempt!=null)cleanup(attempt,"site_removed");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);}
|
||||
private games.dmg.fantasystick.events.CampEventBridge campEvents;
|
||||
public void attachCampEvents(games.dmg.fantasystick.events.CampEventBridge value){campEvents=Objects.requireNonNull(value);}
|
||||
private games.dmg.fantasystick.events.ChampionEventBridge championEvents;
|
||||
public void attachChampionEvents(games.dmg.fantasystick.events.ChampionEventBridge value){championEvents=Objects.requireNonNull(value);}
|
||||
private games.dmg.fantasystick.dungeon.DungeonState.Dungeon captureDungeonDefinition(UUID id){try{return dungeonDefinitions.apply(id).orElse(null);}catch(RuntimeException unavailable){return null;}}
|
||||
private games.dmg.fantasystick.events.DungeonAuditEventBridge dungeonEvents;
|
||||
private java.util.function.Function<UUID,Optional<games.dmg.fantasystick.dungeon.DungeonState.Dungeon>> dungeonDefinitions=ignored->Optional.empty();
|
||||
public void attachDungeonEvents(games.dmg.fantasystick.events.DungeonAuditEventBridge events,java.util.function.Function<UUID,Optional<games.dmg.fantasystick.dungeon.DungeonState.Dungeon>> definitions){dungeonEvents=Objects.requireNonNull(events);dungeonDefinitions=Objects.requireNonNull(definitions);}
|
||||
private games.dmg.fantasystick.events.AdventureDayResetEventBridge dayResetEvents;
|
||||
public void attachDayResetEvents(games.dmg.fantasystick.events.AdventureDayResetEventBridge value){dayResetEvents=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;}
|
||||
public CompletionStage<Boolean> publishPendingReset(){var reset=directory.snapshot().reset();if(reset==null)return CompletableFuture.completedFuture(true);if(resetWork!=null)return resetWork;var audit=dayResetEvents;if(audit!=null)audit.cleanupObserved(reset,attempts.size(),resetParticipants.size());for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt,"catalog_reset");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->{if(audit!=null)audit.cleanupAcknowledged(reset,resetParticipants.size());return directory.completeReset(reset.operation()).whenComplete((published,failure)->{if(failure==null&&Boolean.TRUE.equals(published)&&audit!=null)audit.completed(reset);});}).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){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 games.dmg.fantasystick.events.CampEventBridge.Actor championQuestActor(games.dmg.fantasystick.events.CampEventBridge.Actor actor,AdventureQuestState.Giver giver){
|
||||
var facts=new LinkedHashMap<String,Object>(actor==null?Map.of("actor_kind","unavailable"):actor.captured());
|
||||
if(giver!=null){facts.put("giver_id",giver.id().toString());facts.put("giver_location",Map.of("world_id",giver.world().toString(),"x",giver.x(),"y",giver.y(),"z",giver.z()));facts.put("giver_capture_quality","original_menu_target");}
|
||||
else facts.put("giver_capture_quality","unavailable");
|
||||
return new games.dmg.fantasystick.events.CampEventBridge.Actor(actor==null?null:actor.context(),facts);
|
||||
}
|
||||
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).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.");}));}
|
||||
@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;}var audit=campEvents;var championAudit=championEvents;var actor=audit==null?null:audit.actor(player,null);var championActor=championQuestActor(actor,holder.giver);UUID operation=UUID.randomUUID();var capturedSite=directory.findSite(active.site()).orElse(null);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(active.site());directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->{if(failure==null&&changed){if(audit!=null)audit.questTransition("adventure.quest.abandoned",operation,actor,active,capturedSite,null,"player_abandoned");if(championAudit!=null)championAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,null,"player_abandoned");if(dungeonAudit!=null)try{dungeonAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,originalDungeon,"player_abandoned");}catch(RuntimeException ignored){}}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;var campAudit=campEvents;var championAudit=championEvents;var campActor=campAudit==null?null:campAudit.actor(player,null);var championActor=championQuestActor(campActor,holder.giver);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(site);UUID operation=UUID.randomUUID();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 chLine truncated
|
||||
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();}
|
||||
public void tracking(Player player,Boolean enabled){var context=context(player);if(context==null){player.sendMessage("Quest tracking refused: actively play a living fantasy character first.");return;}var quest=directory.activeFor(context.character,context.partyId).filter(value->value.snapshot().contains(context.character)).orElse(null);if(quest==null){player.sendMessage("Quest tracking refused: this character has no eligible active quest.");refreshTracking(player);return;}if(enabled==null){player.sendMessage("Quest tracking is "+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+" for the current adventure quest.");return;}directory.setTracking(quest.id(),context.character,enabled).whenComplete((status,failure)->schedule(()->{if(failure!=null||status==AdventureQuestDirectory.TrackingStatus.INELIGIBLE){player.sendMessage("Quest tracking change failed; retained state is unchanged.");return;}refreshTracking(player);player.sendMessage("Quest tracking is now "+(enabled?"on.":"off."));}));}
|
||||
public void inspect(Player player){var context=context(player);if(context==null){player.sendMessage("No active fantasy character.");return;}var quest=directory.activeFor(context.character,context.partyId).orElse(null);if(quest==null){player.sendMessage("No active adventure quest.");return;}var site=directory.findSite(quest.site()).orElse(null);if(site==null){player.sendMessage("Active quest site is unavailable.");return;}player.sendMessage(site.displayName()+"; state="+(attempts.containsKey(site.id())?"active":"waiting")+"; expires "+quest.expiresAt()+"; Quest Compass guidance="+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+".");}
|
||||
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt);attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt);attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){var type=directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null);if(type==AdventureQuestState.SiteType.DUNGEON||type==AdventureQuestState.SiteType.FISHING)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
|
||||
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt,"catalog_or_utc_expired");attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt,"no_eligible_accepted_character_remained");attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){var type=directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null);if(type==AdventureQuestState.SiteType.DUNGEON||type==AdventureQuestState.SiteType.FISHING)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;var activation=eligibleInside(quest.site()).findAny();if(activation.isPresent())start(quest.site(),quest.catalog(),activation.orElseThrow());}}
|
||||
private void showArenaBoundaries(){for(UUID siteId:directory.active().stream().map(AdventureQuestState.Quest::site).distinct().toList()){var site=directory.findSite(siteId).orElse(null);if(site==null||site.type()==AdventureQuestState.SiteType.DUNGEON)continue;var world=plugin.getServer().getWorld(site.world());if(world==null)continue;var center=new Location(world,site.x(),site.y(),site.z());var points=perimeter(arena(site));for(var player:plugin.getServer().getOnlinePlayers()){if(eligible(player,siteId)==null)continue;var viewer=player.getLocation();if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world())||viewer.distanceSquared(center)>64d*64d)continue;for(var point:points)particles.viewer(player,Particle.END_ROD,new Location(world,site.x()+point[0],site.y()+0.2,site.z()+point[1]),1,0,0,0,0);}}}
|
||||
private static List<double[]> perimeter(AdventureQuestState.Arena arena){var result=new ArrayList<double[]>();if(arena.shape()==AdventureQuestState.ArenaShape.CIRCLE){int count=Math.min(256,Math.max(16,(int)Math.ceil(Math.PI*arena.xBlocks())));for(int index=0;index<count;index++){double angle=2*Math.PI*index/count;result.add(new double[]{Math.cos(angle)*arena.xBlocks(),Math.sin(angle)*arena.xBlocks()});}}else{double halfX=arena.xBlocks()/2d,halfZ=arena.zBlocks()/2d;edge(result,-halfX,halfX,-halfZ,true);edge(result,-halfX,halfX,halfZ,true);edge(result,-halfZ,halfZ,-halfX,false);edge(result,-halfZ,halfZ,halfX,false);}return result.size()<=256?List.copyOf(result):List.copyOf(result.subList(0,256));}
|
||||
private static void edge(List<double[]> result,double from,double to,double fixed,boolean xAxis){for(double value=from;value<to;value+=2)result.add(xAxis?new double[]{value,fixed}:new double[]{fixed,value});result.add(xAxis?new double[]{to,fixed}:new double[]{fixed,to});}
|
||||
@@ -108,10 +144,33 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private java.util.stream.Stream<Player> eligibleInside(UUID site){var value=directory.findSite(site).orElse(null);return value==null?java.util.stream.Stream.empty():eligibleInside(site,arena(value));}
|
||||
private java.util.stream.Stream<Player> eligibleInside(UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return java.util.stream.Stream.empty();return plugin.getServer().getOnlinePlayers().stream().map(Player.class::cast).filter(player->eligible(player,site)!=null).filter(player->inside(player.getLocation(),value,arena));}
|
||||
private AdventureQuestState.Quest eligible(Player player,UUID site){var context=context(player);if(context==null)return null;var quest=directory.activeFor(context.character,context.partyId).orElse(null);return quest!=null&&quest.site().equals(site)&&quest.snapshot().contains(context.character)?quest:null;}
|
||||
private void start(UUID site,UUID catalog){var value=directory.findSite(site).orElse(null);if(value==null||value.type()==AdventureQuestState.SiteType.DUNGEON||value.type()==AdventureQuestState.SiteType.FISHING)return;if(value.type()==AdventureQuestState.SiteType.CHAMPION){startChampion(value,catalog);return;}starting.add(site);directory.roster(site).whenComplete((roster,failure)->schedule(()->{starting.remove(site);if(failure!=null||closed||attempts.containsKey(site))return;var current=directory.findSite(site).orElse(null);if(current==null)return;var world=plugin.getServer().getWorld(current.world());if(world==null||!world.isChunkLoaded(section(current.x()),section(current.z())))return;var rules=settings.rules();var attempt=new Attempt(site,catalog,current.type(),arena(current),rules.resetSeconds());attempts.put(site,attempt);for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);}));}
|
||||
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 Map<String,Object> captureArenaActivation(Player player,AdventureQuestState.Site site,AdventureQuestState.Arena arena){
|
||||
var facts=new LinkedHashMap<String,Object>();facts.put("capture_quality","unavailable");facts.put("observed_at",clock.instant().toString());
|
||||
try{
|
||||
for(var field:games.dmg.fantasystick.events.CampEventBridge.arenaData(site,arena).entrySet()){
|
||||
if(field.getValue() instanceof Map<?,?> geometry)for(var part:geometry.entrySet())facts.put(field.getKey()+"_"+part.getKey(),part.getValue());
|
||||
else facts.put(field.getKey(),field.getValue());
|
||||
}
|
||||
facts.put("player_id",player.getUniqueId().toString());
|
||||
var context=context(player);
|
||||
if(context!=null){facts.put("character_id",context.character.toString());facts.put("session_id",context.active.session().toString());
|
||||
var quest=directory.activeFor(context.character,context.partyId).filter(value->value.site().equals(site.id())).orElse(null);
|
||||
if(quest!=null){facts.put("quest_id",quest.id().toString());facts.put("unit_id",quest.unit().toString());facts.put("unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));}
|
||||
}
|
||||
var at=player.getLocation();
|
||||
if(at!=null&&at.getWorld()!=null&&Double.isFinite(at.getX())&&Double.isFinite(at.getY())&&Double.isFinite(at.getZ())){
|
||||
facts.put("location_world_id",at.getWorld().getUID().toString());facts.put("location_x",at.getX());facts.put("location_y",at.getY());facts.put("location_z",at.getZ());
|
||||
double dx=at.getX()-site.x(),dz=at.getZ()-site.z();boolean within=at.getWorld().getUID().equals(site.world())&&arena.contains(dx,dz);
|
||||
boolean edge=arena.shape()==AdventureQuestState.ArenaShape.CIRCLE?dx*dx+dz*dz==arena.xBlocks()*(double)arena.xBlocks():Math.abs(dx)==arena.xBlocks()/2d||Math.abs(dz)==arena.zBlocks()/2d;
|
||||
facts.put("boundary_result",within?edge?"edge":"inside":"outside");facts.put("capture_quality","native_activation_observed");
|
||||
}else facts.put("location_capture_quality","unavailable");
|
||||
}catch(RuntimeException unavailable){/* Independent original metadata cannot interrupt an eligible native start. */}
|
||||
return Map.copyOf(facts);
|
||||
}
|
||||
private void start(UUID site,UUID catalog,Player activation){var value=directory.findSite(site).orElse(null);if(value==null||value.type()==AdventureQuestState.SiteType.DUNGEON||value.type()==AdventureQuestState.SiteType.FISHING)return;var activationFacts=captureArenaActivation(activation,value,arena(value));if(value.type()==AdventureQuestState.SiteType.CHAMPION){startChampion(value,catalog,activationFacts);return;}starting.add(site);directory.roster(site).whenComplete((roster,failure)->schedule(()->{starting.remove(site);if(failure!=null||closed||attempts.containsKey(site))return;var current=directory.findSite(site).orElse(null);if(current==null)return;var world=plugin.getServer().getWorld(current.world());if(world==null||!world.isChunkLoaded(section(current.x()),section(current.z())))return;var rules=settings.rules();var attempt=new Attempt(site,catalog,current.type(),arena(current),rules.resetSeconds());attempt.auditSite=current;attempt.auditActivation=activationFacts;attempt.auditRoster=List.copyOf(roster);attempts.put(site,attempt);boolean spawned=false;try{for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);spawned=true;}finally{auditAttempt("adventure.camp.started",attempt,"eligible_accepted_unit_entered",spawned);}}));}
|
||||
private void startChampion(AdventureQuestState.Site site,UUID catalog,Map<String,Object> activation){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());Attempt observed=null;boolean spawned=false;try{var rules=settings.rules();var attempt=new Attempt(site.id(),catalog,site.type(),arena(site),rules.resetSeconds());observed=attempt;attempt.auditSite=site;attempt.auditActivation=activation;attempt.auditRoster=List.of(org.bukkit.entity.EntityType.VINDICATOR);attempt.auditConfiguration=Map.of("requested_health",rules.championHealth(),"requested_damage",rules.championDamage());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);spawned=true;}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{try{if(observed!=null){captureChampionConfiguration(observed);auditAttempt("adventure.camp.started",observed,spawned?"eligible_accepted_unit_entered":"native_spawn_or_configuration_failed",spawned);}}catch(RuntimeException ignored){/* Audit cannot interrupt native lifecycle. */}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());}
|
||||
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);}UUID entityId=mob.getUniqueId();attempt.entities.add(entityId);auditNativeEnemy(attempt,entityId,mob);}
|
||||
@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){
|
||||
@@ -148,6 +207,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
}
|
||||
if(attempt.entities.isEmpty()){attempt.defeatedEntity=event.getEntity().getUniqueId();
|
||||
attempt.defeatLocation=trophyLocation(event.getEntity().getLocation());attempt.defeatedAt=clock.instant();
|
||||
if(attempt.type==AdventureQuestState.SiteType.CHAMPION)captureChampionDefeat(attempt,event);
|
||||
clear(attempt);}}
|
||||
private static Map<String,Object> trophyLocation(Location location){
|
||||
if(location==null||location.getWorld()==null)return Map.of("status","unavailable");
|
||||
@@ -158,7 +218,79 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
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 void clear(Attempt attempt){attempts.remove(attempt.site);if(attempt.bossBar!=null)attempt.bossBar.removeAll();readyAt.put(attempt.site,clock.instant().plusSeconds(attempt.resetSeconds));auditAttempt("adventure.camp.cleared",attempt,"final_native_enemy_defeated",true);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 static void auditNativeEnemy(Attempt attempt,UUID id,Mob mob){
|
||||
try{if(id!=null){var type=mob.getType();attempt.auditEntities.put(id,type==null?"unavailable":type.name());}}
|
||||
catch(RuntimeException unavailable){if(id!=null)attempt.auditEntities.put(id,"unavailable");}
|
||||
}
|
||||
private void auditAttempt(String type,Attempt attempt,String reason,boolean succeeded){
|
||||
try{
|
||||
boolean champion=attempt.type==AdventureQuestState.SiteType.CHAMPION;
|
||||
if(attempt.auditSite==null||(champion?championEvents==null:campEvents==null||attempt.type!=AdventureQuestState.SiteType.CAMP))return;
|
||||
var entities=attempt.auditEntities.entrySet().stream().map(entry->Map.<String,Object>of("entity_id",entry.getKey().toString(),"entity_type",entry.getValue())).toList();
|
||||
var units=new ArrayList<Map<String,Object>>();
|
||||
for(UUID id:attempt.represented.stream().sorted().toList()){
|
||||
var quest=directory.findQuest(id).orElse(null);var unit=new LinkedHashMap<String,Object>();unit.put("quest_id",id.toString());
|
||||
if(quest!=null){unit.put("unit_id",quest.unit().toString());unit.put("unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));unit.put("captured_mode",quest.participationMode().value());}
|
||||
else unit.put("unit_capture_quality","unavailable");units.add(Map.copyOf(unit));
|
||||
}
|
||||
var catalog=directory.snapshot().catalog();boolean current=catalog!=null&&catalog.revision().equals(attempt.catalog)&&directory.offerOpen(attempt.site);
|
||||
var facts=new games.dmg.fantasystick.events.CampEventBridge.CampAttempt(attempt.id,attempt.catalog,
|
||||
attempt.auditSite,attempt.arena,attempt.resetSeconds,attempt.auditRoster.stream().map(Enum::name).toList(),entities,units,
|
||||
attempt.entities.size(),attempt.defeatedEntity,attempt.defeatLocation,attempt.defeatedAt,current,
|
||||
type.equals("adventure.camp.reset")||type.equals("adventure.camp.started")?auditNativePresence(attempt):Map.of("status","not_applicable"),attempt.auditActivation);
|
||||
if(champion)championEvents.attemptTransition(switch(type){case "adventure.camp.started"->"adventure.champion.spawned";case "adventure.camp.cleared"->"adventure.champion.defeated";case "adventure.camp.reset"->"adventure.champion.reset";default->throw new IllegalArgumentException("Unknown champion transition");},facts,attempt.auditConfiguration,reason,succeeded);
|
||||
else campEvents.attemptTransition(type,facts,reason,succeeded);
|
||||
}catch(RuntimeException ignored){/* Metadata cannot spawn, reset, represent, clear or award gameplay. */}
|
||||
}
|
||||
private static void captureChampionConfiguration(Attempt attempt){
|
||||
var facts=new LinkedHashMap<String,Object>(attempt.auditConfiguration);var champion=attempt.champion;
|
||||
for(var field:Map.of("health",Attribute.MAX_HEALTH,"damage",Attribute.ATTACK_DAMAGE).entrySet()){
|
||||
try{var attribute=champion==null?null:champion.getAttribute(field.getValue());double value=attribute==null?Double.NaN:attribute.getBaseValue();
|
||||
if(Double.isFinite(value)&&value>0){facts.put("configured_"+field.getKey(),value);facts.put(field.getKey()+"_capture_quality","native_base_value_observed");}
|
||||
else facts.put(field.getKey()+"_capture_quality","unavailable");
|
||||
}catch(RuntimeException unavailable){facts.put(field.getKey()+"_capture_quality","unavailable");}
|
||||
}
|
||||
try{var name=champion==null?null:champion.customName();
|
||||
if(name!=null){facts.put("champion_name",net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(name));facts.put("champion_name_capture_quality","native_name_observed");}
|
||||
else facts.put("champion_name_capture_quality","unavailable");
|
||||
}catch(RuntimeException unavailable){facts.put("champion_name_capture_quality","unavailable");}
|
||||
attempt.auditConfiguration=Map.copyOf(facts);
|
||||
}
|
||||
private void captureChampionDefeat(Attempt attempt,EntityDeathEvent event){
|
||||
try{
|
||||
var source=new LinkedHashMap<String,Object>();source.put("capture_quality","captured_native_death");
|
||||
source.put("damage_action_capture_quality","unavailable_not_in_native_death");
|
||||
org.bukkit.entity.Entity causing=null,direct=null;
|
||||
try{var damage=event.getDamageSource();if(damage!=null)causing=damage.getCausingEntity();}catch(RuntimeException unavailable){}
|
||||
try{var damage=event.getDamageSource();if(damage!=null)direct=damage.getDirectEntity();}catch(RuntimeException unavailable){}
|
||||
captureSourceEntity(source,"causing",causing);captureSourceEntity(source,"direct",direct);
|
||||
if(causing instanceof Player player&&campEvents!=null){
|
||||
var actor=campEvents.actor(player,null);
|
||||
for(var field:Map.of("request_player_id","killer_player_id","request_character_id","killer_character_id","request_session_id","killer_session_id").entrySet()){
|
||||
var value=actor.captured().get(field.getKey());if(value!=null)source.put(field.getValue(),value);
|
||||
}
|
||||
source.put("killer_context_capture_quality",actor.captured().get("actor_capture_quality"));
|
||||
var location=actor.captured().get("actor_location");if(location instanceof Map<?,?> coordinates)
|
||||
for(var field:coordinates.entrySet())source.put("killer_"+field.getKey(),field.getValue());
|
||||
}else source.put("killer_context_capture_quality","unavailable");
|
||||
var facts=new LinkedHashMap<String,Object>(attempt.auditConfiguration);facts.put("defeat_source",Map.copyOf(source));attempt.auditConfiguration=Map.copyOf(facts);
|
||||
}catch(RuntimeException ignored){/* Missing original source metadata remains unavailable, never gameplay authority. */}
|
||||
}
|
||||
private static void captureSourceEntity(Map<String,Object> facts,String prefix,org.bukkit.entity.Entity entity){
|
||||
try{if(entity!=null&&entity.getUniqueId()!=null)facts.put(prefix+"_entity_id",entity.getUniqueId().toString());}catch(RuntimeException unavailable){}
|
||||
try{if(entity!=null&&entity.getType()!=null)facts.put(prefix+"_entity_type",entity.getType().name());}catch(RuntimeException unavailable){}
|
||||
facts.put(prefix+"_capture_quality",facts.containsKey(prefix+"_entity_id")?"native_entity_observed":"unavailable");
|
||||
}
|
||||
private Map<String,Object> auditNativePresence(Attempt attempt){
|
||||
try{
|
||||
var world=plugin.getServer().getWorld(attempt.auditSite.world());if(world==null)return Map.of("status","world_unavailable");
|
||||
if(!world.isChunkLoaded(section(attempt.auditSite.x()),section(attempt.auditSite.z())))return Map.of("status","not_loaded");
|
||||
var entities=world.getEntities();if(entities==null)return Map.of("status","unavailable");
|
||||
int remaining=Math.toIntExact(entities.stream().filter(entity->attempt.auditEntities.containsKey(entity.getUniqueId())).filter(Entity::isValid).count());
|
||||
return Map.of("status","loaded_observed","remaining_native_entities",remaining);
|
||||
}catch(RuntimeException unavailable){return Map.of("status","unavailable");}
|
||||
}
|
||||
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 audit=new LinkedHashMap<String,String>();
|
||||
@@ -191,12 +323,13 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(PlayerLeashEntityEvent event){if(giver(event.getEntity())||managed(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(EntityTeleportEvent event){if((giver(event.getEntity())||managed(event.getEntity()))&&!relocating.contains(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void chunkLoad(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(enemy(entity)){UUID site=site(entity);var attempt=site==null?null:attempts.remove(site);if(attempt!=null){cleanup(attempt);readyAt.put(site,clock.instant());}}}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(enemy(entity)){UUID site=site(entity);var attempt=site==null?null:attempts.remove(site);if(attempt!=null){cleanup(attempt,"chunk_unload");readyAt.put(site,clock.instant());}}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void giverDamage(EntityDamageEvent event){if(giver(event.getEntity()))event.setCancelled(true);}
|
||||
private Player source(EntityDamageByEntityEvent event){if(event.getDamager() instanceof Player player)return player;if(event.getDamager() instanceof org.bukkit.entity.Projectile projectile&&projectile.getShooter() instanceof Player player)return player;return null;}
|
||||
private UUID site(Entity entity){return parse(entity.getPersistentDataContainer().get(siteKey,PersistentDataType.STRING));}
|
||||
private void syncBossBar(Attempt attempt){if(attempt.bossBar==null||attempt.champion==null)return;var viewers=eligibleInside(attempt.site,attempt.arena).collect(java.util.stream.Collectors.toSet());for(var player:plugin.getServer().getOnlinePlayers())if(viewers.contains(player))attempt.bossBar.addPlayer(player);else attempt.bossBar.removePlayer(player);double maximum=Objects.requireNonNull(attempt.champion.getAttribute(Attribute.MAX_HEALTH)).getValue();attempt.bossBar.setProgress(Math.max(0,Math.min(1,attempt.champion.getHealth()/maximum)));}
|
||||
private void cleanup(Attempt attempt){if(attempt.bossBar!=null)attempt.bossBar.removeAll();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity)))entity.remove();attempt.entities.clear();}
|
||||
private void cleanup(Attempt attempt){cleanup(attempt,"native_cleanup");}
|
||||
private void cleanup(Attempt attempt,String reason){boolean cleaned=false;try{if(attempt.bossBar!=null)attempt.bossBar.removeAll();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity)))entity.remove();attempt.entities.clear();cleaned=true;}finally{auditAttempt("adventure.camp.reset",attempt,reason,cleaned);}}
|
||||
private void refreshTrackingPlayers(){refreshTrackingPlayers(false);}
|
||||
private void refreshTrackingPlayers(boolean actionBar){for(var value:plugin.getServer().getOnlinePlayers())refreshTracking(value,actionBar);}
|
||||
private void refreshTracking(Player player){refreshTracking(player,false);}
|
||||
@@ -208,5 +341,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();dyingOrigins.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,"plugin_disable");attempts.clear();dyingOrigins.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
|
||||
}
|
||||
@@ -17,7 +17,32 @@ public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
public enum FishingUpdateStatus { APPLIED, UNCHANGED, STALE, NOT_FOUND }
|
||||
public enum RerollStatus { APPLIED, STALE }
|
||||
public enum TrackingStatus { APPLIED, UNCHANGED, INELIGIBLE }
|
||||
public record AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest) { }
|
||||
public record CatalogPublication(AdventureQuestState.Catalog catalog,UUID operation,String reason,
|
||||
AdventureQuestSettings.Rules settings,List<AdventureQuestState.Site> selected,
|
||||
games.dmg.fantasystick.events.CampEventBridge.Actor actor,boolean originalCapture) {
|
||||
public CatalogPublication{selected=List.copyOf(selected);}
|
||||
}
|
||||
private volatile java.util.function.Consumer<CatalogPublication> catalogObserver=ignored->{};
|
||||
private CatalogPublication pendingCatalogPublication;
|
||||
public void attachCatalogEvents(java.util.function.Consumer<CatalogPublication> observer){catalogObserver=Objects.requireNonNull(observer);}
|
||||
public record ResetPublication(AdventureQuestState.Reset reset,AdventureQuestState.Catalog previous,
|
||||
AdventureQuestState.Catalog catalog,String reason,int expiredQuests,
|
||||
games.dmg.fantasystick.events.CampEventBridge.Actor actor){}
|
||||
private volatile java.util.function.Consumer<ResetPublication> resetObserver=ignored->{};
|
||||
public void attachDayResetEvents(java.util.function.Consumer<ResetPublication> observer){resetObserver=Objects.requireNonNull(observer);}
|
||||
public record QuestExpiry(UUID operation,String reason,AdventureQuestState.Quest quest,AdventureQuestState.Site site,
|
||||
games.dmg.fantasystick.events.CampEventBridge.Actor actor){}
|
||||
private volatile java.util.function.Consumer<QuestExpiry> expiryObserver=ignored->{};
|
||||
public void attachQuestExpiryEvents(java.util.function.Consumer<QuestExpiry> observer){expiryObserver=Objects.requireNonNull(observer);}
|
||||
private volatile java.util.function.Consumer<QuestExpiry> championExpiryObserver=ignored->{};
|
||||
public void attachChampionQuestExpiryEvents(java.util.function.Consumer<QuestExpiry> observer){championExpiryObserver=Objects.requireNonNull(observer);}
|
||||
private volatile java.util.function.Consumer<QuestExpiry> dungeonExpiryObserver=ignored->{};
|
||||
private final Map<UUID,AdventureQuestState.Site> originalQuestSites=new HashMap<>();
|
||||
private volatile java.util.function.Predicate<UUID> dungeonQuestIdentity=ignored->false;
|
||||
public void attachDungeonQuestExpiryEvents(java.util.function.Consumer<QuestExpiry> observer,java.util.function.Predicate<UUID> identity){dungeonExpiryObserver=Objects.requireNonNull(observer);dungeonQuestIdentity=Objects.requireNonNull(identity);}
|
||||
public record AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest,Optional<AdventureQuestState.Site> capturedSite) {
|
||||
public AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest){this(status,quest,Optional.empty());}
|
||||
}
|
||||
public record ModeResult(ModeStatus status,Optional<AdventureQuestState.Site> site) { }
|
||||
public record FishingUpdateResult(FishingUpdateStatus status,Optional<AdventureQuestState.Site> site) { }
|
||||
public record RerollResult(RerollStatus status,Optional<AdventureQuestState.Catalog> catalog) { }
|
||||
@@ -41,25 +66,28 @@ public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
public Optional<AdventureQuestState.Quest> findQuest(UUID id){return snapshot.quests().stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z){return placeSite(type,name,world,x,y,z,null,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena){return placeSite(type,name,world,x,y,z,arena,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena,AdventureQuestState.ParticipationMode mode){if(type==AdventureQuestState.SiteType.FISHING)throw new IllegalArgumentException("Use complete fishing definition");var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena,mode);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena,AdventureQuestState.ParticipationMode mode){if(type==AdventureQuestState.SiteType.FISHING)throw new IllegalArgumentException("Use complete fishing definition");var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena,mode,null,(type==AdventureQuestState.SiteType.CAMP||type==AdventureQuestState.SiteType.CHAMPION)?UUID.randomUUID():null);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeFishingSite(String name,String itemName,UUID world,double x,double y,double z,int radius,java.math.BigDecimal chance,BigInteger required,BigInteger rewardXp,BigInteger rewardCoins,AdventureQuestState.ParticipationMode mode){var fishing=new AdventureQuestState.FishingDefinition(UUID.randomUUID(),itemName,required,chance,rewardXp,rewardCoins,radius);var value=new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.FISHING,name,world,x,y,z,AdventureQuestState.Arena.circle(radius),mode,fishing);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
public CompletableFuture<FishingUpdateResult> updateFishing(UUID id,AdventureQuestState.FishingDefinition expected,AdventureQuestState.FishingDefinition requested){var current=findSite(id).orElse(null);if(current==null||current.type()!=AdventureQuestState.SiteType.FISHING)return CompletableFuture.completedFuture(new FishingUpdateResult(FishingUpdateStatus.NOT_FOUND,Optional.empty()));if(!current.fishing().equals(expected))return CompletableFuture.completedFuture(new FishingUpdateResult(FishingUpdateStatus.STALE,Optional.of(current)));return updateFishingSite(id,current,current.name(),requested,current.participationMode());}
|
||||
public CompletableFuture<FishingUpdateResult> updateFishingSite(UUID id,AdventureQuestState.Site expected,String name,AdventureQuestState.FishingDefinition requested,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expected);Objects.requireNonNull(requested);Objects.requireNonNull(mode);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()==AdventureQuestState.SiteType.FISHING).findFirst();if(found.isEmpty())return result(state,new FishingUpdateResult(FishingUpdateStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(!current.equals(expected))return result(state,new FishingUpdateResult(FishingUpdateStatus.STALE,Optional.of(current)));if(!current.fishing().itemId().equals(requested.itemId()))throw new IllegalArgumentException("Fishing item identity cannot change");var changed=new AdventureQuestState.Site(current.id(),current.type(),name,current.world(),current.x(),current.y(),current.z(),AdventureQuestState.Arena.circle(requested.radius()),mode,requested);if(changed.equals(current))return result(state,new FishingUpdateResult(FishingUpdateStatus.UNCHANGED,Optional.of(current)));var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new FishingUpdateResult(FishingUpdateStatus.APPLIED,Optional.of(changed)));});}
|
||||
public CompletableFuture<ModeResult> setSiteMode(UUID id,AdventureQuestState.ParticipationMode expected,AdventureQuestState.ParticipationMode requested){Objects.requireNonNull(expected);Objects.requireNonNull(requested);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,new ModeResult(ModeStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(current.participationMode()!=expected)return result(state,new ModeResult(ModeStatus.STALE,Optional.of(current)));if(current.participationMode()==requested)return result(state,new ModeResult(ModeStatus.UNCHANGED,Optional.of(current)));var changed=new AdventureQuestState.Site(current.id(),current.type(),current.name(),current.world(),current.x(),current.y(),current.z(),current.arena(),requested,current.fishing());var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new ModeResult(ModeStatus.APPLIED,Optional.of(changed)));});}
|
||||
public CompletableFuture<FishingUpdateResult> updateFishingSite(UUID id,AdventureQuestState.Site expected,String name,AdventureQuestState.FishingDefinition requested,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expected);Objects.requireNonNull(requested);Objects.requireNonNull(mode);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()==AdventureQuestState.SiteType.FISHING).findFirst();if(found.isEmpty())return result(state,new FishingUpdateResult(FishingUpdateStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(!current.equals(expected))return result(state,new FishingUpdateResult(FishingUpdateStatus.STALE,Optional.of(current)));if(!current.fishing().itemId().equals(requested.itemId()))throw new IllegalArgumentException("Fishing item identity cannot change");var changed=new AdventureQuestState.Site(current.id(),current.type(),name,current.world(),current.x(),current.y(),current.z(),AdventureQuestState.Arena.circle(requested.radius()),mode,requested,current.registrationRevision());if(changed.equals(current))return result(state,new FishingUpdateResult(FishingUpdateStatus.UNCHANGED,Optional.of(current)));var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new FishingUpdateResult(FishingUpdateStatus.APPLIED,Optional.of(changed)));});}
|
||||
public CompletableFuture<ModeResult> setSiteMode(UUID id,AdventureQuestState.ParticipationMode expected,AdventureQuestState.ParticipationMode requested){Objects.requireNonNull(expected);Objects.requireNonNull(requested);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,new ModeResult(ModeStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(current.participationMode()!=expected)return result(state,new ModeResult(ModeStatus.STALE,Optional.of(current)));if(current.participationMode()==requested)return result(state,new ModeResult(ModeStatus.UNCHANGED,Optional.of(current)));var changed=new AdventureQuestState.Site(current.id(),current.type(),current.name(),current.world(),current.x(),current.y(),current.z(),current.arena(),requested,current.fishing(),(current.type()==AdventureQuestState.SiteType.CAMP||current.type()==AdventureQuestState.SiteType.CHAMPION)?UUID.randomUUID():current.registrationRevision());var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new ModeResult(ModeStatus.APPLIED,Optional.of(changed)));});}
|
||||
public CompletableFuture<Optional<AdventureQuestState.Site>> removeSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();var quests=state.quests().stream().filter(v->!v.site().equals(id)).toList();var rosters=new HashMap<>(state.rosters());rosters.remove(id);return result(copy(state,sites,state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),rosters),found);});}
|
||||
public CompletableFuture<Optional<AdventureQuestState.Site>> removeDungeonSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()==AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests().stream().filter(v->!v.site().equals(id)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
|
||||
public CompletableFuture<AdventureQuestState.Giver> placeGiver(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new AdventureQuestState.Giver(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);return mutate(state->{var values=new ArrayList<>(state.givers());values.add(value);return result(copy(state,state.sites(),List.copyOf(values),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
public CompletableFuture<AdventureQuestState.Giver> placeGiver(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new AdventureQuestState.Giver(UUID.randomUUID(),name,world,x,y,z,yaw,pitch,UUID.randomUUID());return mutate(state->{var values=new ArrayList<>(state.givers());values.add(value);return result(copy(state,state.sites(),List.copyOf(values),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
public CompletableFuture<Optional<AdventureQuestState.Giver>> removeGiver(UUID id){return mutate(state->{var found=state.givers().stream().filter(v->v.id().equals(id)).findFirst();if(found.isEmpty())return result(state,Optional.empty());return result(copy(state,state.sites(),state.givers().stream().filter(v->!v.id().equals(id)).toList(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
|
||||
public CompletableFuture<AdventureQuestState.Catalog> appendOffer(UUID site){return mutate(state->{if(state.reset()!=null||site(state,site).filter(v->v.type()==AdventureQuestState.SiteType.DUNGEON).isEmpty()||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC))))throw new IllegalArgumentException("Dungeon offer unavailable");if(state.catalog().sites().contains(site)){if(!state.catalog().closed().contains(site))return result(state,state.catalog());var closed=new HashSet<>(state.catalog().closed());closed.remove(site);var reopened=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites(),closed);return result(copy(state,state.sites(),state.givers(),reopened,state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),reopened);}if(state.catalog().sites().size()>=256)throw new IllegalArgumentException("Adventure offer limit reached");var offers=new ArrayList<>(state.catalog().sites());offers.add(site);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),offers,state.catalog().closed());return result(copy(state,state.sites(),state.givers(),catalog,state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),catalog);});}
|
||||
public CompletableFuture<Boolean> closeOffer(UUID site){return mutate(state->{if(state.catalog()==null||!state.catalog().sites().contains(site)||state.catalog().closed().contains(site))return result(state,false);var closed=new HashSet<>(state.catalog().closed());closed.add(site);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites(),closed);var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();return result(copy(state,state.sites(),state.givers(),catalog,quests,state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
|
||||
public CompletableFuture<AdventureQuestState.Catalog> ensureToday(){return mutate(state->{if(state.reset()!=null)return result(state,state.catalog());var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(state.catalog()!=null&&state.catalog().day().equals(today))return result(expire(state),state.catalog());var prior=state.catalog();var catalog=roll(state,today,prior==null?Set.of():Set.copyOf(prior.sites()));var reset=prior==null?null:new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,catalog);});}
|
||||
public record OfferPublication(UUID site,AdventureQuestState.Catalog before,AdventureQuestState.Catalog after,boolean changed){}
|
||||
public CompletableFuture<AdventureQuestState.Catalog> appendOffer(UUID site){return appendOfferWithEvidence(site).thenApply(OfferPublication::after);}
|
||||
public CompletableFuture<OfferPublication> appendOfferWithEvidence(UUID site){return mutate(state->{if(state.reset()!=null||site(state,site).filter(v->v.type()==AdventureQuestState.SiteType.DUNGEON).isEmpty()||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC))))throw new IllegalArgumentException("Dungeon offer unavailable");if(state.catalog().sites().contains(site)){if(!state.catalog().closed().contains(site))return result(state,new OfferPublication(site,state.catalog(),state.catalog(),false));var closed=new HashSet<>(state.catalog().closed());closed.remove(site);var reopened=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites(),closed);return result(copy(state,state.sites(),state.givers(),reopened,state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new OfferPublication(site,state.catalog(),reopened,true));}if(state.catalog().sites().size()>=256)throw new IllegalArgumentException("Adventure offer limit reached");var offers=new ArrayList<>(state.catalog().sites());offers.add(site);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),offers,state.catalog().closed());return result(copy(state,state.sites(),state.givers(),catalog,state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new OfferPublication(site,state.catalog(),catalog,true));});}
|
||||
public CompletableFuture<Boolean> closeOffer(UUID site){return mutate("offer_closed",state->{if(state.catalog()==null||!state.catalog().sites().contains(site)||state.catalog().closed().contains(site))return result(state,false);var closed=new HashSet<>(state.catalog().closed());closed.add(site);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites(),closed);var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();return result(copy(state,state.sites(),state.givers(),catalog,quests,state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
|
||||
public CompletableFuture<AdventureQuestState.Catalog> ensureToday(){return mutate(state->{if(state.reset()!=null)return result(state,state.catalog());var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(state.catalog()!=null&&state.catalog().day().equals(today))return result(expire(state),state.catalog());var prior=state.catalog();var rolled=roll(state,today,prior==null?Set.of():Set.copyOf(prior.sites()));var catalog=rolled.catalog();var reset=prior==null?null:new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return new Result<>(next,catalog,new CatalogPublication(catalog,reset==null?catalog.revision():reset.operation(),prior==null?"initial_current_day":"utc_day_rollover",rolled.settings(),rolled.selected(),null,true));});}
|
||||
public CompletableFuture<RerollResult> rerollToday(){var current=snapshot.catalog();return rerollToday(current==null?null:current.revision());}
|
||||
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision){return mutate(original->{var state=expire(original);var current=state.catalog();UUID actual=current==null?null:current.revision();if(state.reset()!=null||!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));boolean sameDay=current!=null&¤t.day().equals(today);var catalog=roll(state,today,sameDay?Set.copyOf(current.sites()):Set.of());var reset=new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)));});}
|
||||
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision){return rerollToday(expectedRevision,null);}
|
||||
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision,games.dmg.fantasystick.events.CampEventBridge.Actor actor){return mutate(original->{var state=expire(original);var current=state.catalog();UUID actual=current==null?null:current.revision();if(state.reset()!=null||!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));boolean sameDay=current!=null&¤t.day().equals(today);var rolled=roll(state,today,sameDay?Set.copyOf(current.sites()):Set.of());var catalog=rolled.catalog();var reset=new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return new Result<>(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)),new CatalogPublication(catalog,reset.operation(),"admin_full_reroll",rolled.settings(),rolled.selected(),actor,true));});}
|
||||
public CompletableFuture<Boolean> completeReset(UUID operation){return mutate(state->{if(state.reset()==null||!state.reset().operation().equals(operation))return result(state,false);return result(copyReset(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),null),true);});}
|
||||
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){var offered=findSite(site).orElse(null);return offered==null?CompletableFuture.completedFuture(new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty())):accept(catalog,site,type,unit,members,offered.participationMode());}
|
||||
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members,AdventureQuestState.ParticipationMode expectedMode){Objects.requireNonNull(expectedMode);var requestedMembers=Set.copyOf(members);return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));var offered=site(state,site).filter(this::available).orElse(null);if(!current.sites().contains(site)||current.closed().contains(site)||offered==null)return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));if(offered.participationMode()!=expectedMode)return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!offered.participationMode().permits(type))return result(state,new AcceptResult(Acceptance.POLICY,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=requestedMembers.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||!Collections.disjoint(q.snapshot(),requestedMembers));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,requestedMembers,current.expiresAt(),reward.xp(),reward.coins(),offered.participationMode(),offered.fishing()==null?null:offered.fishing().terms());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:requestedMembers)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
|
||||
public CompletableFuture<Integer> expireOfferQuests(UUID site){return mutate(state->{var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
|
||||
public CompletableFuture<Boolean> abandon(UUID quest,UUID unit){return mutate(state->{var found=state.quests().stream().filter(q->q.id().equals(quest)&&q.unit().equals(unit)).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(quest)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
|
||||
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members,AdventureQuestState.ParticipationMode expectedMode){Objects.requireNonNull(expectedMode);var requestedMembers=Set.copyOf(members);return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));var offered=site(state,site).filter(this::available).orElse(null);if(!current.sites().contains(site)||current.closed().contains(site)||offered==null)return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));if(offered.participationMode()!=expectedMode)return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!offered.participationMode().permits(type))return result(state,new AcceptResult(Acceptance.POLICY,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=requestedMembers.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||!Collections.disjoint(q.snapshot(),requestedMembers));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,requestedMembers,current.expiresAt(),reward.xp(),reward.coins(),offered.participationMode(),offered.fishing()==null?null:offered.fishing().terms());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:requestedMembers)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest),Optional.of(offered)));});}
|
||||
public CompletableFuture<Integer> expireOfferQuests(UUID site){return mutate("offer_expired",state->{var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
|
||||
public CompletableFuture<Boolean> abandon(UUID quest,UUID unit){return mutate("player_abandoned",state->{var found=state.quests().stream().filter(q->q.id().equals(quest)&&q.unit().equals(unit)).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(quest)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
|
||||
public CompletableFuture<List<EntityType>> roster(UUID site){return mutate(state->{state=expire(state);if(state.reset()!=null||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!state.catalog().sites().contains(site)||find(state.sites(),site).filter(v->v.type()==AdventureQuestState.SiteType.CAMP).isEmpty())throw new IllegalArgumentException("Unavailable camp");var existing=state.rosters().get(site);if(existing!=null)return result(state,existing);var rolled=CampRoster.roll(settings.get().campEnemies(),random);var rosters=new HashMap<>(state.rosters());rosters.put(site,rolled);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),rosters),rolled);});}
|
||||
public CompletableFuture<Boolean> complete(UUID questId,Set<UUID> recipients){return mutate(state->{var quest=state.quests().stream().filter(q->q.id().equals(questId)).findFirst().orElse(null);if(quest==null)return result(state,false);var units=new HashSet<>(state.unitClaims());units.add(new AdventureQuestState.UnitClaim(quest.catalog(),quest.site(),quest.unitType(),quest.unit()));var characters=new HashSet<>(state.characterClaims());for(UUID recipient:recipients)characters.add(new AdventureQuestState.CharacterClaim(quest.catalog(),quest.site(),recipient));return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(questId)).toList(),units,characters,state.rosters()),true);});}
|
||||
public List<AdventureQuestState.Quest> active(){return expireView(snapshot).quests();}
|
||||
@@ -78,10 +106,11 @@ public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
public CompletableFuture<Optional<AdventureQuestState.RewardClaim>> beginLegacyClaim(UUID character){return mutate(original->{var state=expire(original);var pending=state.rewards().operations().stream().filter(value->value.character().equals(character)).findFirst();if(pending.isPresent())return result(state,pending);var allocations=state.rewards().allocations().stream().filter(value->value.character().equals(character)).sorted(Comparator.comparing(AdventureQuestState.RewardAllocation::id)).toList();if(allocations.isEmpty())return result(state,Optional.empty());BigInteger xp=allocations.stream().map(AdventureQuestState.RewardAllocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(AdventureQuestState.RewardAllocation::coins).reduce(BigInteger.ZERO,BigInteger::add);var ids=allocations.stream().map(AdventureQuestState.RewardAllocation::id).collect(java.util.stream.Collectors.toSet());var first=allocations.getFirst();UUID operation=UUID.nameUUIDFromBytes((character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(java.nio.charset.StandardCharsets.UTF_8));var claim=new AdventureQuestState.RewardClaim(operation,first.catalog(),first.site(),character,ids,xp,coins);var operations=new ArrayList<>(state.rewards().operations());operations.add(claim);var ledger=new AdventureQuestState.RewardLedger(state.rewards().allocations(),operations,state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),Optional.of(claim));});}
|
||||
public CompletableFuture<Optional<AdventureQuestState.RewardClaim>> beginClaim(UUID catalog,UUID site,UUID character){return mutate(original->{var state=expire(original);var current=state.catalog();if(current==null||!current.revision().equals(catalog)||!current.sites().contains(site)||!clock.instant().isBefore(current.expiresAt()))return result(state,Optional.empty());var pending=state.rewards().operations().stream().filter(value->value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)).findFirst();if(pending.isPresent())return result(state,pending);var allocations=state.rewards().allocations().stream().filter(value->value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)).toList();if(allocations.isEmpty())return result(state,Optional.empty());BigInteger xp=allocations.stream().map(AdventureQuestState.RewardAllocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(AdventureQuestState.RewardAllocation::coins).reduce(BigInteger.ZERO,BigInteger::add);var ids=allocations.stream().map(AdventureQuestState.RewardAllocation::id).collect(java.util.stream.Collectors.toSet());UUID operation=UUID.nameUUIDFromBytes((catalog+":"+site+":"+character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(java.nio.charset.StandardCharsets.UTF_8));var claim=new AdventureQuestState.RewardClaim(operation,catalog,site,character,ids,xp,coins);var operations=new ArrayList<>(state.rewards().operations());operations.add(claim);var ledger=new AdventureQuestState.RewardLedger(state.rewards().allocations(),operations,state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),Optional.of(claim));});}
|
||||
public CompletableFuture<Boolean> finishClaim(UUID operation){return mutate(state->{var claim=state.rewards().operations().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(claim==null)return result(state,false);var allocations=state.rewards().allocations().stream().filter(value->!claim.allocations().contains(value.id())).toList();var operations=state.rewards().operations().stream().filter(value->!value.id().equals(operation)).toList();var receipts=new HashSet<>(state.rewards().receipts());receipts.add(new AdventureQuestState.RewardReceipt(claim.catalog(),claim.site(),claim.character()));var ledger=new AdventureQuestState.RewardLedger(allocations,operations,receipts);return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),true);});}
|
||||
public CompletableFuture<Integer> expireParties(java.util.function.Predicate<UUID> exists){return mutate(state->{var quests=state.quests().stream().filter(q->q.unitType()!=AdventureQuestState.UnitType.PARTY||exists.test(q.unit())).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
|
||||
public CompletableFuture<Integer> expireParties(java.util.function.Predicate<UUID> exists){return mutate("party_invalidated",state->{var quests=state.quests().stream().filter(q->q.unitType()!=AdventureQuestState.UnitType.PARTY||exists.test(q.unit())).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
|
||||
private AdventureQuestState expire(AdventureQuestState state){var view=expireView(state);return view.equals(state)?state:view;}
|
||||
private AdventureQuestState expireView(AdventureQuestState state){if(state.catalog()==null||clock.instant().isBefore(state.catalog().expiresAt()))return state;return new AdventureQuestState(state.sites(),state.givers(),state.catalog(),List.of(),state.unitClaims(),state.characterClaims(),state.rosters(),Set.of(),pendingRewards(state.rewards()),state.reset());}
|
||||
private AdventureQuestState.Catalog roll(AdventureQuestState state,LocalDate day,Set<UUID> previous){var candidates=candidateSites(state);var alternatives=candidates.stream().map(AdventureQuestState.Site::id).filter(id->!previous.contains(id)).collect(java.util.stream.Collectors.toCollection(ArrayList::new));var old=candidates.stream().map(AdventureQuestState.Site::id).filter(previous::contains).collect(java.util.stream.Collectors.toCollection(ArrayList::new));shuffle(alternatives);shuffle(old);int count=Math.min(settings.get().dailySiteCount(),candidates.size());var selected=new ArrayList<UUID>();selected.addAll(alternatives.subList(0,Math.min(count,alternatives.size())));if(selected.size()<count)selected.addAll(old.subList(0,count-selected.size()));return new AdventureQuestState.Catalog(day,UUID.randomUUID(),clock.instant(),selected);}
|
||||
private record Rolled(AdventureQuestState.Catalog catalog,AdventureQuestSettings.Rules settings,List<AdventureQuestState.Site> selected){}
|
||||
private Rolled roll(AdventureQuestState state,LocalDate day,Set<UUID> previous){var candidates=candidateSites(state);var alternatives=candidates.stream().map(AdventureQuestState.Site::id).filter(id->!previous.contains(id)).collect(java.util.stream.Collectors.toCollection(ArrayList::new));var old=candidates.stream().map(AdventureQuestState.Site::id).filter(previous::contains).collect(java.util.stream.Collectors.toCollection(ArrayList::new));shuffle(alternatives);shuffle(old);var capturedSettings=settings.get();int count=Math.min(capturedSettings.dailySiteCount(),candidates.size());var selected=new ArrayList<UUID>();selected.addAll(alternatives.subList(0,Math.min(count,alternatives.size())));if(selected.size()<count)selected.addAll(old.subList(0,count-selected.size()));return new Rolled(new AdventureQuestState.Catalog(day,UUID.randomUUID(),clock.instant(),selected),capturedSettings,candidates.stream().filter(site->selected.contains(site.id())).toList());}
|
||||
private void shuffle(List<UUID> values){for(int i=values.size()-1;i>0;i--){int j=random.nextInt(i+1);var v=values.get(i);values.set(i,values.get(j));values.set(j,v);}}
|
||||
private List<AdventureQuestState.Site> candidateSites(AdventureQuestState state){var values=new LinkedHashMap<UUID,AdventureQuestState.Site>();state.sites().stream().filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON).forEach(value->values.put(value.id(),value));dungeonSites.get().forEach(value->values.put(value.id(),value));return List.copyOf(values.values());}
|
||||
private Optional<AdventureQuestState.Site> site(AdventureQuestState state,UUID id){var stored=find(state.sites(),id);return stored.isPresent()?stored:dungeonSites.get().stream().filter(value->value.id().equals(id)).findFirst();}
|
||||
@@ -93,9 +122,55 @@ public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
private static AdventureQuestState.RewardLedger pendingRewards(AdventureQuestState.RewardLedger rewards){return rewards;}
|
||||
private static Set<AdventureQuestState.Tracking> validTracking(Set<AdventureQuestState.Tracking> tracking,List<AdventureQuestState.Quest> quests){var active=quests.stream().collect(java.util.stream.Collectors.toMap(AdventureQuestState.Quest::id,value->value));return tracking.stream().filter(value->{var quest=active.get(value.quest());return quest!=null&&quest.snapshot().contains(value.character());}).collect(java.util.stream.Collectors.toUnmodifiableSet());}
|
||||
private static UUID rewardId(AdventureQuestState.Quest quest,UUID source,UUID character){return UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+source+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));}
|
||||
private record Result<T>(AdventureQuestState state,T value){}
|
||||
private static <T>Result<T> result(AdventureQuestState state,T value){return new Result<>(state,value);}
|
||||
private <T>CompletableFuture<T> mutate(java.util.function.Function<AdventureQuestState,Result<T>> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Adventure directory closed"));return future;}pending++;}worker.execute(()->{try{var result=operation.apply(snapshot);if(result.state()!=snapshot){repository.save(result.state());snapshot=result.state();}future.complete(result.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
|
||||
private record Result<T>(AdventureQuestState state,T value,CatalogPublication publication){}
|
||||
private static <T>Result<T> result(AdventureQuestState state,T value){return new Result<>(state,value,null);}
|
||||
private void observeCatalogCommit(AdventureQuestState before,Result<?> result){
|
||||
try{
|
||||
var after=result.state();
|
||||
if(result.publication()!=null)pendingCatalogPublication=result.publication();
|
||||
if(after.reset()!=null)return;
|
||||
if(pendingCatalogPublication==null&&before.reset()!=null&&after.catalog()!=null){
|
||||
pendingCatalogPublication=new CatalogPublication(after.catalog(),before.reset().operation(),"recovery_pending_publication",null,
|
||||
candidateSites(after).stream().filter(site->after.catalog().sites().contains(site.id())).toList(),null,false);
|
||||
}
|
||||
if(pendingCatalogPublication!=null&&after.catalog()!=null&&pendingCatalogPublication.catalog().revision().equals(after.catalog().revision())){
|
||||
var publication=pendingCatalogPublication;pendingCatalogPublication=null;catalogObserver.accept(publication);
|
||||
}
|
||||
}catch(RuntimeException ignored){/* Metadata observation cannot change durable catalog authority. */}
|
||||
}
|
||||
private void observeResetCommit(AdventureQuestState before,Result<?> result){
|
||||
try{
|
||||
var after=result.state();var publication=result.publication();
|
||||
if(before.reset()==null&&after.reset()!=null&&publication!=null)
|
||||
resetObserver.accept(new ResetPublication(after.reset(),before.catalog(),after.catalog(),
|
||||
publication.reason().equals("admin_full_reroll")?"manual":"utc_rollover",before.quests().size(),publication.actor()));
|
||||
}catch(RuntimeException ignored){/* Observing reset installation cannot change its durable authority. */}
|
||||
}
|
||||
private void observeQuestCommit(AdventureQuestState before,Result<?> result,String reason,UUID operation){
|
||||
try{
|
||||
var after=result.state();var remaining=after.quests().stream().map(AdventureQuestState.Quest::id).collect(java.util.stream.Collectors.toSet());
|
||||
if(result.value() instanceof AcceptResult accepted&&accepted.status()==Acceptance.APPLIED&&accepted.quest().isPresent()&&accepted.capturedSite().isPresent())originalQuestSites.put(accepted.quest().orElseThrow().id(),accepted.capturedSite().orElseThrow());
|
||||
if(reason.equals("player_abandoned")){originalQuestSites.keySet().retainAll(remaining);return;}
|
||||
for(var quest:before.quests()){
|
||||
if(remaining.contains(quest.id()))continue;
|
||||
var definition=find(before.sites(),quest.site()).orElse(originalQuestSites.get(quest.id()));
|
||||
boolean dungeon=definition==null?dungeonQuestIdentity.test(quest.site()):definition.type()==AdventureQuestState.SiteType.DUNGEON;
|
||||
if(!dungeon&&(definition==null||(definition.type()!=AdventureQuestState.SiteType.CAMP&&definition.type()!=AdventureQuestState.SiteType.CHAMPION)))continue;
|
||||
var claim=new AdventureQuestState.UnitClaim(quest.catalog(),quest.site(),quest.unitType(),quest.unit());
|
||||
if(after.unitClaims().contains(claim)&&!before.unitClaims().contains(claim))continue;
|
||||
String observed=!clock.instant().isBefore(quest.expiresAt())?"utc_deadline":
|
||||
!dungeon&&find(after.sites(),quest.site()).isEmpty()?"site_removed":result.publication()!=null?
|
||||
result.publication().reason().equals("admin_full_reroll")?"full_day_reroll":"utc_day_rollover":reason;
|
||||
var publication=result.publication();
|
||||
var observer=dungeon?dungeonExpiryObserver:definition.type()==AdventureQuestState.SiteType.CHAMPION?championExpiryObserver:expiryObserver;
|
||||
observer.accept(new QuestExpiry(publication==null?operation:publication.operation(),observed,quest,definition,
|
||||
publication==null?null:publication.actor()));
|
||||
}
|
||||
originalQuestSites.keySet().retainAll(remaining);
|
||||
}catch(RuntimeException ignored){/* Removed authority is not reversible by audit metadata. */}
|
||||
}
|
||||
private <T>CompletableFuture<T> mutate(java.util.function.Function<AdventureQuestState,Result<T>> operation){return mutate("authoritative_state_transition",operation);}
|
||||
private <T>CompletableFuture<T> mutate(String reason,java.util.function.Function<AdventureQuestState,Result<T>> operation){var future=new CompletableFuture<T>();UUID observation=UUID.randomUUID();synchronized(this){if(closed){future.completeExceptionally(new IOException("Adventure directory closed"));return future;}pending++;}worker.execute(()->{try{var before=snapshot;var result=operation.apply(before);if(result.state()!=before){repository.save(result.state());snapshot=result.state();observeResetCommit(before,result);observeQuestCommit(before,result,reason,observation);observeCatalogCommit(before,result);}future.complete(result.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
|
||||
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);}
|
||||
|
||||
@@ -41,16 +41,18 @@ public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog ca
|
||||
public boolean contains(double xOffset,double zOffset){return shape==ArenaShape.CIRCLE?xOffset*xOffset+zOffset*zOffset<=xBlocks*(double)xBlocks:Math.abs(xOffset)<=xBlocks/2d&&Math.abs(zOffset)<=zBlocks/2d;}
|
||||
public String display(){return shape==ArenaShape.CIRCLE?"circle radius="+xBlocks:"rectangle x="+xBlocks+" z="+zBlocks;}
|
||||
}
|
||||
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode,FishingDefinition fishing) {
|
||||
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode,FishingDefinition fishing,UUID registrationRevision) {
|
||||
public Site { Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(world);finite(x,y,z);Objects.requireNonNull(participationMode);if((type==SiteType.FISHING)!=(fishing!=null)||fishing!=null&&(!Arena.circle(fishing.radius()).equals(arena)))throw new IllegalArgumentException("Invalid fishing site"); }
|
||||
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode,FishingDefinition fishing){this(id,type,name,world,x,y,z,arena,participationMode,fishing,null);}
|
||||
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode){this(id,type,name,world,x,y,z,arena,participationMode,null);}
|
||||
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena){this(id,type,name,world,x,y,z,arena,ParticipationMode.PARTY_AVAILABLE,null);}
|
||||
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z){this(id,type,name,world,x,y,z,null,ParticipationMode.PARTY_AVAILABLE,null);}
|
||||
public String displayName(){return name.isBlank()?switch(type){case CAMP->"Monster Camp";case CHAMPION->"Champion Site";case DUNGEON->"Dungeon";case FISHING->"Fishing Site";}:name;}
|
||||
public Arena arenaOrDefault(int radius){return arena==null?Arena.circle(radius):arena;}
|
||||
}
|
||||
public record Giver(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch) {
|
||||
public record Giver(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch,UUID registrationRevision) {
|
||||
public Giver { Objects.requireNonNull(id);name=validName(name);Objects.requireNonNull(world);finite(x,y,z);if(!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid facing"); }
|
||||
public Giver(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch){this(id,name,world,x,y,z,yaw,pitch,null);}
|
||||
public String displayName(){return name.isBlank()?"Quest Giver":name;}
|
||||
}
|
||||
public record Catalog(LocalDate day,UUID revision,Instant createdAt,List<UUID> sites,Set<UUID> closed) {
|
||||
|
||||
@@ -67,6 +67,9 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
fishingEvents.trophyEarned(trophy.earned());
|
||||
});
|
||||
}
|
||||
public boolean earnedTrophy(UUID id){return fishing.trophy(id)
|
||||
.map(value->value.phase()==FishingState.TrophyPhase.EARNED)
|
||||
.orElseGet(()->fishing.snapshot().trophyReceipts().contains(id));}
|
||||
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){
|
||||
@@ -265,8 +268,7 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
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){
|
||||
if(captured!=null&&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());
|
||||
@@ -274,6 +276,10 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
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()));
|
||||
}else if(captured.type()==FishingState.TrophyType.DUNGEON){
|
||||
data.put("boss_id",captured.audit().getOrDefault("boss_id","unavailable_legacy_capture"));
|
||||
data.put("room_id",captured.audit().getOrDefault("room_id","unavailable_legacy_capture"));
|
||||
data.put("boss_death_location",games.dmg.fantasystick.events.FishingEventBridge.savedDefeatLocation(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());
|
||||
|
||||
@@ -896,7 +896,12 @@ 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();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);
|
||||
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)
|
||||
|| games.dmg.fantasystick.passive.QuickerRegenerationII.ID.equals(passive)
|
||||
|| games.dmg.fantasystick.passive.HealthSteal.ID.equals(passive)
|
||||
|| games.dmg.fantasystick.passive.SpeedI.ID.equals(passive)
|
||||
|| games.dmg.fantasystick.passive.LowLightVision.ID.equals(passive)
|
||||
|| games.dmg.fantasystick.passive.StickKnockback.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;
|
||||
|
||||
@@ -29,6 +29,7 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
|
||||
private final Map<UUID, Instant> readyAt = new HashMap<>();
|
||||
private games.dmg.fantasystick.events.BasicAttackEventBridge events;
|
||||
private games.dmg.fantasystick.events.StickKnockbackEventBridge knockbackEvents;
|
||||
private Pending pending;
|
||||
private boolean closed;
|
||||
|
||||
@@ -77,6 +78,11 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
public void attachKnockbackEvents(games.dmg.fantasystick.events.StickKnockbackEventBridge bridge) {
|
||||
if (closed || knockbackEvents != null) throw new IllegalStateException("Knockback events already attached");
|
||||
knockbackEvents = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) {
|
||||
if (input.kind() != ControlInput.Kind.BASIC_ATTACK || input.gesture() != ControlInput.Gesture.ATTACK) return;
|
||||
Instant now = clock.instant();
|
||||
@@ -98,7 +104,11 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
pending = attempt;
|
||||
boolean completed = false;
|
||||
try {
|
||||
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
|
||||
var measurementSource = new games.dmg.fantasystick.training.TrainingDamage.Source(
|
||||
action == null ? java.util.UUID.randomUUID() : action.id(), input.session().player(), record.id(),
|
||||
record.name(), input.session().session(), "Basic Attack", null);
|
||||
var measured = trainingDamage.apply(input.player(), target, measurementSource,
|
||||
() -> 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);
|
||||
@@ -110,7 +120,8 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
|
||||
if (attempt.knockback && !trainingDamage.target(target) && input.stillCurrent()
|
||||
&& sessions.current(input.session())) {
|
||||
games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
|
||||
if (knockbackEvents == null) games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
|
||||
else knockbackEvents.apply(action, input, record, target, sessions);
|
||||
}
|
||||
}
|
||||
if (events != null) events.resolved(action, attempt.accepted ? "accepted" : !completed ? "failed"
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package games.dmg.fantasystick.dungeon;
|
||||
|
||||
import games.dmg.fantasystick.events.CharacterBossEventBridge;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Read-only, native-thread observations. Never owns, removes or restores a potion layer. */
|
||||
final class CharacterBossEffectAudit {
|
||||
private record Key(UUID target, PotionEffectType type) {}
|
||||
private record Application(UUID id, UUID shell, CharacterBossEventBridge.Facts facts,
|
||||
PotionEffect signature) {}
|
||||
record NativeCall(CharacterBossEventBridge.Facts facts, LivingEntity target,
|
||||
PotionEffect requested, PotionEffect prior, boolean returned,
|
||||
List<EntityPotionEffectEvent> nativeEvents) {
|
||||
NativeCall { nativeEvents = List.copyOf(nativeEvents); }
|
||||
}
|
||||
private final Map<Key, Application> owned = new HashMap<>();
|
||||
private final CharacterBossEventBridge events;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
|
||||
CharacterBossEffectAudit(CharacterBossEventBridge events, Consumer<Runnable> nextTick) {
|
||||
this.events = events;
|
||||
this.nextTick = nextTick;
|
||||
}
|
||||
|
||||
static PotionEffect layer(LivingEntity target, PotionEffectType type) {
|
||||
return target.getActivePotionEffects().stream().filter(effect -> effect.getType().equals(type)).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
// A passive has no cast event. Require the exact requested infinite native layer after confirmed spawn.
|
||||
void passive(CharacterBossEventBridge.Facts shell, LivingEntity target, String id,
|
||||
PotionEffect requested, Map<String,Object> location) {
|
||||
if (requested.equals(layer(target, requested.getType()))) {
|
||||
var facts = events.transition(shell, Map.of("passive_id", id,
|
||||
"source_location", location, "target_location", location,
|
||||
"target_kind", "native_character_shell"));
|
||||
apply(facts, target, requested, "confirmed_equipped_passive");
|
||||
}
|
||||
}
|
||||
|
||||
String resolve(List<NativeCall> calls) {
|
||||
if (calls.isEmpty()) return null;
|
||||
boolean accepted = false, cancelled = false, unchanged = false;
|
||||
for (var call : calls) {
|
||||
// Ambiguous reentrant observations cannot stand in for the original single native call.
|
||||
if (call.nativeEvents().size() != 1) continue;
|
||||
var event = call.nativeEvents().getFirst();
|
||||
if (event.isCancelled()) { cancelled = true; continue; }
|
||||
var current = layer(call.target(), call.requested().getType());
|
||||
if (call.requested().equals(call.prior()) && call.requested().equals(current)) {
|
||||
unchanged = true;
|
||||
continue;
|
||||
}
|
||||
// Source may be null in pinned Bukkit. Exact per-call target/request plus final decision,
|
||||
// actual returned acceptance and a changed native layer establish this observation.
|
||||
if (call.returned() && call.requested().equals(event.getNewEffect())
|
||||
&& call.requested().equals(current)) {
|
||||
apply(call.facts(), call.target(), call.requested(), "native_call_changed_layer_confirmed");
|
||||
accepted = true;
|
||||
}
|
||||
}
|
||||
return accepted ? "accepted" : cancelled ? "cancelled" : unchanged ? "no_op" : "unconfirmed";
|
||||
}
|
||||
|
||||
private void apply(CharacterBossEventBridge.Facts facts, LivingEntity target,
|
||||
PotionEffect signature, String status) {
|
||||
var key = new Key(target.getUniqueId(), signature.getType());
|
||||
var previous = owned.get(key);
|
||||
if (previous != null) end(key, previous, nativeEnd(previous, null, target),
|
||||
"native_layer_replaced", "confirmed_replacement");
|
||||
var id = UUID.randomUUID();
|
||||
var data = new LinkedHashMap<String,Object>();
|
||||
data.put("application_id", id.toString());
|
||||
data.put("effect_type", signature.getType().getKey().toString());
|
||||
data.put("amplifier", signature.getAmplifier());
|
||||
data.put("duration_ticks", signature.getDuration());
|
||||
data.put("particles", signature.hasParticles());
|
||||
data.put("target_entity_id", target.getUniqueId().toString());
|
||||
data.put("application_status", status);
|
||||
data.put("causation", "original_character_boss_native_effect");
|
||||
var original = events.transition(facts, data);
|
||||
owned.put(key, new Application(id, UUID.fromString(facts.data().get("native_entity_id").toString()),
|
||||
original, signature));
|
||||
events.effectApplied(original, id, Map.of("outcome", "accepted"));
|
||||
}
|
||||
|
||||
// Removal listeners run before the server mutates its layer. Confirm after dispatch AND mutation.
|
||||
void removal(EntityPotionEffectEvent event) {
|
||||
if (event.getAction() != EntityPotionEffectEvent.Action.REMOVED
|
||||
&& event.getAction() != EntityPotionEffectEvent.Action.CHANGED) return;
|
||||
if (!(event.getEntity() instanceof LivingEntity target)) return;
|
||||
var old = event.getOldEffect();
|
||||
if (old == null) return;
|
||||
var key = new Key(target.getUniqueId(), old.getType());
|
||||
var application = owned.get(key);
|
||||
if (application == null || !sameOwnedLayer(application.signature(), old)) return;
|
||||
var cause = event.getCause().name().toLowerCase(java.util.Locale.ROOT);
|
||||
var endFacts = nativeEnd(application, event.getSource(), target);
|
||||
nextTick.accept(() -> {
|
||||
try {
|
||||
if (!event.isCancelled() && owned.get(key) == application
|
||||
&& (layer(target, old.getType()) == null
|
||||
|| !sameOwnedLayer(application.signature(), layer(target, old.getType())))) {
|
||||
end(key, application, endFacts, "native_" + cause,
|
||||
"native_layer_no_longer_matches");
|
||||
}
|
||||
} catch (RuntimeException unavailable) { /* Audit must not affect native expiration. */ }
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean sameOwnedLayer(PotionEffect original, PotionEffect observed) {
|
||||
return original.getType().equals(observed.getType()) && original.getAmplifier() == observed.getAmplifier()
|
||||
&& original.hasParticles() == observed.hasParticles() && original.isAmbient() == observed.isAmbient()
|
||||
&& (original.getDuration() == PotionEffect.INFINITE_DURATION
|
||||
? observed.getDuration() == PotionEffect.INFINITE_DURATION
|
||||
: observed.getDuration() <= original.getDuration());
|
||||
}
|
||||
|
||||
void shellEnded(Entity shell, String reason) {
|
||||
var id = shell.getUniqueId();
|
||||
for (var entry : new ArrayList<>(owned.entrySet())) {
|
||||
var application = entry.getValue();
|
||||
if (application.shell().equals(id) && entry.getKey().target().equals(id))
|
||||
end(entry.getKey(), application, nativeEnd(application, shell, shell),
|
||||
reason, "native_shell_lifetime_ended");
|
||||
}
|
||||
}
|
||||
|
||||
void shellEndUnconfirmed(Entity shell, String reason, String status) {
|
||||
var id = shell.getUniqueId();
|
||||
for (var entry : new ArrayList<>(owned.entrySet())) {
|
||||
var key = entry.getKey(); var application = entry.getValue();
|
||||
if (!application.shell().equals(id) || !key.target().equals(id)) continue;
|
||||
try {
|
||||
var original = nativeEnd(application, shell, shell);
|
||||
events.effectEndPending(original, application.id(), Map.of("phase", "native_effect_end_confirmation_pending",
|
||||
"application_id", application.id().toString(), "outcome", "unconfirmed",
|
||||
"end_status", status, "reason", reason, "causation", "original_native_shell_remove_returned"));
|
||||
// One native-thread confirmation, not a worker/replay retry or gameplay cleanup.
|
||||
nextTick.accept(() -> {
|
||||
try { if (!shell.isValid() && owned.get(key) == application)
|
||||
end(key, application, original, "native_invalid_confirmed_after_original_remove",
|
||||
"native_shell_lifetime_ended_after_original_unconfirmed_remove");
|
||||
} catch (RuntimeException unavailable) { /* Keep immutable pending application evidence, never invent an end. */ }
|
||||
});
|
||||
} catch (RuntimeException unavailable) { /* Each original application remains independent. */ }
|
||||
}
|
||||
}
|
||||
|
||||
private CharacterBossEventBridge.Facts nativeEnd(Application application, Entity source, Entity target) {
|
||||
var original = application.facts();
|
||||
var data = new LinkedHashMap<String,Object>();
|
||||
if (original.data().containsKey("source_location"))
|
||||
data.put("application_source_location", original.data().get("source_location"));
|
||||
if (original.data().containsKey("target_location"))
|
||||
data.put("application_target_location", original.data().get("target_location"));
|
||||
data.put("application_observed_at", original.request().observedAt().toString());
|
||||
data.put("source_location", nativeEndLocation(source));
|
||||
data.put("target_location", nativeEndLocation(target));
|
||||
if (source != null) data.put("end_event_source_entity_id", source.getUniqueId().toString());
|
||||
data.put("end_capture_quality", "original_native_transition_before_confirmation");
|
||||
return events.transition(original, data);
|
||||
}
|
||||
|
||||
private static Map<String,Object> nativeEndLocation(Entity entity) {
|
||||
try {
|
||||
var at = entity == null ? null : entity.getLocation();
|
||||
if (at == null || at.getWorld() == null)
|
||||
return Map.of("location_status", "unavailable_at_original_native_end");
|
||||
var data = new LinkedHashMap<String,Object>();
|
||||
data.put("world_id", at.getWorld().getUID().toString());
|
||||
data.put("world_name", java.util.Objects.toString(at.getWorld().getName(), "unavailable"));
|
||||
data.put("x", at.getX()); data.put("y", at.getY()); data.put("z", at.getZ());
|
||||
data.put("yaw", (double)at.getYaw()); data.put("pitch", (double)at.getPitch());
|
||||
data.put("captured_at", java.time.Instant.now().toString());
|
||||
data.put("quality", "native_original");
|
||||
return Map.copyOf(data);
|
||||
} catch (RuntimeException unavailable) {
|
||||
return Map.of("location_status", "unavailable_at_original_native_end");
|
||||
}
|
||||
}
|
||||
|
||||
private void end(Key key, Application application, CharacterBossEventBridge.Facts facts,
|
||||
String reason, String status) {
|
||||
if (!owned.remove(key, application)) return;
|
||||
events.effectEnded(facts, application.id(), Map.of("application_id", application.id().toString(),
|
||||
"reason", reason, "cleanup_status", status, "outcome", "ended",
|
||||
"causation", "original_character_boss_effect_application"));
|
||||
}
|
||||
}
|
||||
@@ -17,33 +17,40 @@ import org.bukkit.potion.PotionEffectType;
|
||||
/** Registered command adapter for durable dungeon authoring and catalog administration. */
|
||||
public final class DungeonCommands {
|
||||
private static final String PERMISSION="fantasystick.admin";
|
||||
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();private final AdventureAdminTeleport teleports;
|
||||
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();private final AdventureAdminTeleport teleports;private games.dmg.fantasystick.events.DungeonEventBridge auditEvents;private games.dmg.fantasystick.events.DungeonAuditEventBridge ownerEvents;private games.dmg.fantasystick.events.CharacterBossEventBridge characterBossEvents;
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick){this(plugin,directory,controller,adventures,nextTick,id->CompletableFuture.completedFuture(Optional.empty()),id->false,id->false);}
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick,Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters,Predicate<UUID> activeCharacter,Predicate<UUID> partyCharacter){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.adventures=Objects.requireNonNull(adventures);this.nextTick=Objects.requireNonNull(nextTick);this.characters=Objects.requireNonNull(characters);this.activeCharacter=Objects.requireNonNull(activeCharacter);this.partyCharacter=Objects.requireNonNull(partyCharacter);locations=new games.dmg.fantasystick.adventure.AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
|
||||
public void attachEvents(games.dmg.fantasystick.events.DungeonEventBridge events){this.auditEvents=Objects.requireNonNull(events);}
|
||||
public void attachOwnerEvents(games.dmg.fantasystick.events.DungeonAuditEventBridge events){this.ownerEvents=Objects.requireNonNull(events);}
|
||||
public void attachCharacterBossEvents(games.dmg.fantasystick.events.CharacterBossEventBridge events){this.characterBossEvents=Objects.requireNonNull(events);}
|
||||
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"mode"->mode(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"reward"->reward(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
|
||||
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}int nameStart=2;var mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args[2].equals("--mode")){if(args.length<5){usage(sender);return;}try{mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}nameStart=4;}else if(args[2].startsWith("--")){usage(sender);return;}String name=join(args,nameStart);if(name.isBlank()){usage(sender);return;}var location=player.getLocation();directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch(),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+"); mode="+value.participationMode().value()+".":"Dungeon creation failed; desired state is unchanged.")));}
|
||||
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}int nameStart=2;var mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args[2].equals("--mode")){if(args.length<5){usage(sender);return;}try{mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}nameStart=4;}else if(args[2].startsWith("--")){usage(sender);return;}String name=join(args,nameStart);if(name.isBlank()){usage(sender);return;}var location=player.getLocation();var audit=ownerEvents;var actor=audit==null?null:audit.actor(sender);var operation=UUID.randomUUID();directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch(),mode).whenComplete((value,failure)->{if(failure==null&&audit!=null)try{audit.created(operation,actor,value);}catch(RuntimeException ignored){/* Audit cannot undo acknowledged authoring. */}respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+"); mode="+value.participationMode().value()+".":"Dungeon creation failed; desired state is unchanged."));});}
|
||||
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}if(directory.snapshot().dungeons().isEmpty()){sender.sendMessage("No dungeons registered.");return;}directory.snapshot().dungeons().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; mode="+value.participationMode().value()+"; rooms="+value.rooms().size()+"; completion="+(value.rewardConfigured()?value.completionReward().xp()+" XP, "+value.completionReward().coins()+" coins":"not configured")+"."));}
|
||||
private void mode(CommandSender sender,String[] args){if(args.length<3||args.length>4){usage(sender);return;}UUID id=id(args[2]);var current=id==null?null:directory.find(id).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage("Dungeon "+id+" mode="+current.participationMode().value()+".");return;}games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode requested;try{requested=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}directory.setMode(id,current.revision(),requested).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Updated dungeon "+id+" mode="+value.participationMode().value()+" for future acceptances.":"Dungeon mode update refused; the acknowledged definition is unchanged or stale.")));}
|
||||
private void remove(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}if(directory.custodyForDungeon(id).isPresent()){sender.sendMessage("Recover character boss custody before removing this dungeon.");return;}controller.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->directory.remove(id)).whenComplete((removed,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon removal failed; desired state is retained.":removed.isPresent()?"Removed dungeon "+id+".":"Dungeon not found.")));}
|
||||
private void edit(CommandSender sender,String[] args){if(!(sender instanceof Player player)){usage(sender);return;}if(args.length==3&&args[2].equals("done")){sender.sendMessage(controller.done(player.getUniqueId())?"Dungeon editing ended; the dungeon remains disabled.":"No dungeon is being edited.");return;}if(args.length<4||args.length>5||!args[2].equals("start")||args.length==5&&!args[4].equals("confirm")){usage(sender);return;}UUID id=id(args[3]);if(id==null){usage(sender);return;}controller.start(player,id,args.length==5).whenComplete((status,failure)->respond(sender,()->{if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon edit transition failed",failure);sender.sendMessage("Dungeon edit transition failed; retained state is authoritative.");return;}sender.sendMessage(switch(status){case STARTED->"Editing dungeon "+id+"; it is disabled.";case CONFIRM_REQUIRED->"Dungeon has offered, accepted or progressed work; repeat with exact confirm to close and expire it.";case BUSY->"Dungeon editing refused: the editor or dungeon already has an editing lease.";case NOT_FOUND->"Dungeon not found.";});}));}
|
||||
private void entrance(CommandSender sender,String[] args){if(args.length!=3||!args[2].equals("set")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;var location=player.getLocation();if(location.getWorld()==null||!directory.find(dungeon).orElseThrow().world().equals(location.getWorld().getUID())){sender.sendMessage("Dungeon entrance must remain in its world.");return;}directory.setEntrance(dungeon,location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Updated dungeon entrance."));}
|
||||
private void room(CommandSender sender,String[] args){if(!(sender instanceof Player player)||args.length<3){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;switch(args[2]){case"create"->roomCreate(sender,player,dungeon,args);case"list"->{if(args.length!=3){usage(sender);return;}var value=directory.find(dungeon).orElseThrow();if(value.rooms().isEmpty())sender.sendMessage("No rooms defined.");else value.rooms().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" "+v.bounds()+"; mobs="+v.mobs().size()+"; effects="+v.effects().size()+"; boss="+(v.boss()==null?"none":v.boss().type().name().toLowerCase(Locale.ROOT))));}case"remove"->{if(args.length!=4){usage(sender);return;}UUID room=id(args[3]);if(room==null){usage(sender);return;}directory.removeRoom(dungeon,room).whenComplete((value,failure)->{if(failure==null&&value.isPresent())controller.definitionChanged(dungeon);respond(sender,()->sender.sendMessage(failure!=null?"Room removal refused: it would overlap, disconnect or could not be saved.":value.isPresent()?"Removed room "+room+".":"Room not found."));});}case"mob"->mob(sender,player,dungeon,args);case"effect"->effect(sender,player,dungeon,args);case"boss"->boss(sender,player,dungeon,args);default->usage(sender);}}
|
||||
private void roomCreate(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length<6){usage(sender);return;}var location=player.getLocation();int x=location.getBlockX(),y=location.getBlockY()-1,z=location.getBlockZ();if(args[3].equals("corner")){if(args.length<7){usage(sender);return;}Integer xs=integer(args[4]),zs=integer(args[5]),height=integer(args[6]);if(xs==null||zs==null||height==null){usage(sender);return;}directory.addCornerRoom(dungeon,join(args,7),x,y,z,xs,zs,height).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}if(args[3].equals("radius")){Integer radius=integer(args[4]),height=integer(args[5]);if(radius==null||height==null){usage(sender);return;}directory.addRadiusRoom(dungeon,join(args,6),x,y,z,radius,height).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}usage(sender);}
|
||||
private void mode(CommandSender sender,String[] args){if(args.length<3||args.length>4){usage(sender);return;}UUID id=id(args[2]);var current=id==null?null:directory.find(id).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage("Dungeon "+id+" mode="+current.participationMode().value()+".");return;}games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode requested;try{requested=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}var audit=ownerEvents;var actor=audit==null?null:audit.actor(sender);var operation=UUID.randomUUID();directory.setModeWithEvidence(id,current.revision(),requested).whenComplete((change,failure)->{if(failure==null&&audit!=null)try{audit.modeChanged(operation,actor,change.before(),change.after());}catch(RuntimeException ignored){/* Audit cannot undo acknowledged authoring. */}respond(sender,()->sender.sendMessage(failure==null?"Updated dungeon "+id+" mode="+change.after().participationMode().value()+" for future acceptances.":"Dungeon mode update refused; the acknowledged definition is unchanged or stale."));});}
|
||||
private void remove(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}if(directory.custodyForDungeon(id).isPresent()){sender.sendMessage("Recover character boss custody before removing this dungeon.");return;}var request=request(sender);controller.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->observedSave(request,"administrator_remove",()->directory.remove(id))).whenComplete((removed,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon removal failed; desired state is retained.":removed.isPresent()?"Removed dungeon "+id+".":"Dungeon not found.")));}
|
||||
private void edit(CommandSender sender,String[] args){if(!(sender instanceof Player player)){usage(sender);return;}if(args.length==3&&args[2].equals("done")){var request=request(sender);var before=controller.editing(player.getUniqueId()).flatMap(directory::find).orElse(null);boolean ended=controller.done(player.getUniqueId());if(ended&&before!=null&&request.events()!=null)try{request.events().lease("dungeon.edit.ended",request.operation(),request.actor(),before,"administrator_done");}catch(RuntimeException ignored){}sender.sendMessage(ended?"Dungeon editing ended; the dungeon remains disabled.":"No dungeon is being edited.");return;}if(args.length<4||args.length>5||!args[2].equals("start")||args.length==5&&!args[4].equals("confirm")){usage(sender);return;}UUID id=id(args[3]);if(id==null){usage(sender);return;}var request=request(sender);controller.startWithEvidence(player,id,args.length==5).whenComplete((publication,failure)->{var status=publication==null?null:publication.status();if(failure==null&&status==DungeonController.EditStatus.STARTED&&request.events()!=null)try{request.events().lease("dungeon.edit.started",request.operation(),request.actor(),publication.dungeon(),"administrator_start");}catch(RuntimeException ignored){}respond(sender,()->{if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon edit transition failed",failure);sender.sendMessage("Dungeon edit transition failed; retained state is authoritative.");return;}sender.sendMessage(switch(status){case STARTED->"Editing dungeon "+id+"; it is disabled.";case CONFIRM_REQUIRED->"Dungeon has offered, accepted or progressed work; repeat with exact confirm to close and expire it.";case BUSY->"Dungeon editing refused: the editor or dungeon already has an editing lease.";case NOT_FOUND->"Dungeon not found.";});});});}
|
||||
private void entrance(CommandSender sender,String[] args){if(args.length!=3||!args[2].equals("set")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;var location=player.getLocation();if(location.getWorld()==null||!directory.find(dungeon).orElseThrow().world().equals(location.getWorld().getUID())){sender.sendMessage("Dungeon entrance must remain in its world.");return;}observedSave(sender,"administrator_entrance",()->directory.setEntrance(dungeon,location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch())).whenComplete((value,failure)->saved(sender,failure,"Updated dungeon entrance."));}
|
||||
private void room(CommandSender sender,String[] args){if(!(sender instanceof Player player)||args.length<3){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;switch(args[2]){case"create"->roomCreate(sender,player,dungeon,args);case"list"->{if(args.length!=3){usage(sender);return;}var value=directory.find(dungeon).orElseThrow();if(value.rooms().isEmpty())sender.sendMessage("No rooms defined.");else value.rooms().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" "+v.bounds()+"; mobs="+v.mobs().size()+"; effects="+v.effects().size()+"; boss="+(v.boss()==null?"none":v.boss().type().name().toLowerCase(Locale.ROOT))));}case"remove"->{if(args.length!=4){usage(sender);return;}UUID room=id(args[3]);if(room==null){usage(sender);return;}observedSave(sender,"administrator_room_remove",()->directory.removeRoom(dungeon,room)).whenComplete((value,failure)->{if(failure==null&&value.isPresent())controller.definitionChanged(dungeon);respond(sender,()->sender.sendMessage(failure!=null?"Room removal refused: it would overlap, disconnect or could not be saved.":value.isPresent()?"Removed room "+room+".":"Room not found."));});}case"mob"->mob(sender,player,dungeon,args);case"effect"->effect(sender,player,dungeon,args);case"boss"->boss(sender,player,dungeon,args);default->usage(sender);}}
|
||||
private void roomCreate(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length<6){usage(sender);return;}var location=player.getLocation();int x=location.getBlockX(),y=location.getBlockY()-1,z=location.getBlockZ();if(args[3].equals("corner")){if(args.length<7){usage(sender);return;}Integer xs=integer(args[4]),zs=integer(args[5]),height=integer(args[6]);if(xs==null||zs==null||height==null){usage(sender);return;}observedSave(sender,"administrator_room_create",()->directory.addCornerRoom(dungeon,join(args,7),x,y,z,xs,zs,height)).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}if(args[3].equals("radius")){Integer radius=integer(args[4]),height=integer(args[5]);if(radius==null||height==null){usage(sender);return;}observedSave(sender,"administrator_room_create",()->directory.addRadiusRoom(dungeon,join(args,6),x,y,z,radius,height)).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}usage(sender);}
|
||||
private void roomSaved(CommandSender sender,UUID dungeon,DungeonState.Room value,Throwable failure){if(failure==null)controller.definitionChanged(dungeon);String message=failure==null?"Created room "+value.id()+" ("+value.displayName()+").":roomFailure(failure);respond(sender,()->sender.sendMessage(message));}
|
||||
private static String roomFailure(Throwable failure){Throwable cause=failure;while((cause instanceof java.util.concurrent.CompletionException||cause instanceof java.util.concurrent.ExecutionException)&&cause.getCause()!=null)cause=cause.getCause();String detail=cause.getMessage();if(cause instanceof IllegalArgumentException&&"Rooms overlap".equals(detail))return "Room creation refused: it overlaps an existing room.";if(cause instanceof IllegalArgumentException&&detail!=null&&detail.startsWith("Room exceeds configured link distance of ")){String blocks=detail.substring("Room exceeds configured link distance of ".length()).replace(" blocks","");return "Room creation refused: nearest room is farther than the configured "+blocks+"-block link distance.";}if(cause instanceof IllegalArgumentException&&(detail!=null&&(detail.equals("Room dimensions out of range")||detail.equals("Room limit reached"))))return "Room creation refused: dimensions or room limit are out of range.";if(cause instanceof java.io.IOException)return "Room creation failed: persistence is unavailable; desired state is unchanged.";return "Room creation refused: geometry validation failed; desired state is unchanged.";}
|
||||
private void mob(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=5&&args[3].equals("add")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living mob type.");return;}var location=player.getLocation();directory.addMob(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Added mob slot "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeMob(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed mob slot "+id+".":"Mob slot not found."));return;}if(args.length==4&&args[3].equals("list")){if(room.mobs().isEmpty())sender.sendMessage("No ordinary mob slots in this room.");else room.mobs().forEach(value->sender.sendMessage(value.id()+" "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.name()));return;}usage(sender);}
|
||||
private void effect(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=7&&args[3].equals("add")){DungeonState.EffectTarget target;try{target=DungeonState.EffectTarget.valueOf(args[4].toUpperCase(Locale.ROOT));}catch(IllegalArgumentException invalid){usage(sender);return;}String key=args[5].contains(":")?args[5]:"minecraft:"+args[5];var namespaced=NamespacedKey.fromString(key);Integer level=integer(args[6]);boolean particles=args.length<8||Boolean.parseBoolean(args[7]);if(namespaced==null||PotionEffectType.getByKey(namespaced)==null||level==null||args.length>8||args.length==8&&!Set.of("true","false").contains(args[7])){usage(sender);return;}directory.addEffect(dungeon,room.id(),target,key,level,particles).whenComplete((value,failure)->saved(sender,failure,"Added room effect "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeEffect(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed room effect "+id+".":"Room effect not found."));return;}usage(sender);}
|
||||
private void reward(CommandSender sender,String[] args){if(args.length<3||args.length>5){usage(sender);return;}UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage(current.rewardConfigured()?"Dungeon "+dungeon+" completion reward is "+current.completionReward().xp()+" XP and "+current.completionReward().coins()+" coins.":"Dungeon "+dungeon+" completion reward is not configured.");return;}if(current.enabled()||adventures.offerOpen(dungeon)||adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon))){sender.sendMessage("Dungeon reward update requires disabled, closed dungeon state without accepted quests.");return;}if(controller.beingEdited(dungeon)&&(!(sender instanceof Player player)||!controller.editing(player.getUniqueId()).filter(dungeon::equals).isPresent())){sender.sendMessage("Dungeon reward update refused: another administrator owns the editing lease.");return;}BigInteger xp=args.length==5?whole(args[3]):null,coins=args.length==5?whole(args[4]):null;if(xp==null||coins==null){usage(sender);return;}directory.setCompletionReward(dungeon,xp,coins).whenComplete((value,failure)->saved(sender,failure,"Set dungeon completion reward to "+xp+" XP and "+coins+" coins."));}
|
||||
private void boss(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length==5&&args[3].equals("assign-sheet")){assignCharacterBoss(sender,player,dungeon,room,args[4]);return;}if(args.length>=5&&args[3].equals("mob")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living boss type.");return;}var location=player.getLocation();directory.setBoss(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Configured native boss "+type.name().toLowerCase(Locale.ROOT)+"."));return;}if(args.length==4&&args[3].equals("clear")){directory.clearBoss(dungeon,room.id()).whenComplete((value,failure)->saved(sender,failure,"Cleared the room boss."));return;}usage(sender);}
|
||||
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var sheetIdentity=sheets.identity(held).orElse(null);var character=sheetIdentity==null?null:sheetIdentity.character();if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();if(!sheetIdentity.generation().equals(expected.sheetGeneration())||(sheetIdentity.legacy()&&expected.sheetGeneration().signum()!=0)){respond(sender,()->sender.sendMessage("Character boss assignment refused: that sheet generation is no longer authorized."));return;}directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier).whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing.")));return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||!sheets.authorizes(current,expected)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing.")));return;}player.getInventory().setItemInMainHand(null);sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
|
||||
private void mob(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=5&&args[3].equals("add")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living mob type.");return;}var location=player.getLocation();observedSave(sender,"administrator_mob_add",()->directory.addMob(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch())).whenComplete((value,failure)->saved(sender,failure,"Added mob slot "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}observedSave(sender,"administrator_mob_remove",()->directory.removeMob(dungeon,room.id(),id)).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed mob slot "+id+".":"Mob slot not found."));return;}if(args.length==4&&args[3].equals("list")){if(room.mobs().isEmpty())sender.sendMessage("No ordinary mob slots in this room.");else room.mobs().forEach(value->sender.sendMessage(value.id()+" "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.name()));return;}usage(sender);}
|
||||
private void effect(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=7&&args[3].equals("add")){DungeonState.EffectTarget target;try{target=DungeonState.EffectTarget.valueOf(args[4].toUpperCase(Locale.ROOT));}catch(IllegalArgumentException invalid){usage(sender);return;}String key=args[5].contains(":")?args[5]:"minecraft:"+args[5];var namespaced=NamespacedKey.fromString(key);Integer level=integer(args[6]);boolean particles=args.length<8||Boolean.parseBoolean(args[7]);if(namespaced==null||PotionEffectType.getByKey(namespaced)==null||level==null||args.length>8||args.length==8&&!Set.of("true","false").contains(args[7])){usage(sender);return;}observedSave(sender,"administrator_effect_add",()->directory.addEffect(dungeon,room.id(),target,key,level,particles)).whenComplete((value,failure)->saved(sender,failure,"Added room effect "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}observedSave(sender,"administrator_effect_remove",()->directory.removeEffect(dungeon,room.id(),id)).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed room effect "+id+".":"Room effect not found."));return;}usage(sender);}
|
||||
private void reward(CommandSender sender,String[] args){if(args.length<3||args.length>5){usage(sender);return;}UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage(current.rewardConfigured()?"Dungeon "+dungeon+" completion reward is "+current.completionReward().xp()+" XP and "+current.completionReward().coins()+" coins.":"Dungeon "+dungeon+" completion reward is not configured.");return;}if(current.enabled()||adventures.offerOpen(dungeon)||adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon))){sender.sendMessage("Dungeon reward update requires disabled, closed dungeon state without accepted quests.");return;}if(controller.beingEdited(dungeon)&&(!(sender instanceof Player player)||!controller.editing(player.getUniqueId()).filter(dungeon::equals).isPresent())){sender.sendMessage("Dungeon reward update refused: another administrator owns the editing lease.");return;}BigInteger xp=args.length==5?whole(args[3]):null,coins=args.length==5?whole(args[4]):null;if(xp==null||coins==null){usage(sender);return;}var audit=auditEvents;var actor=audit==null?null:audit.actor(sender);var operation=UUID.randomUUID();directory.setCompletionRewardWithEvidence(dungeon,xp,coins).whenComplete((change,failure)->{if(failure==null&&audit!=null)try{audit.rewardChanged(operation,actor,change);}catch(RuntimeException ignored){}saved(sender,failure,"Set dungeon completion reward to "+xp+" XP and "+coins+" coins.");});}
|
||||
private void boss(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length==5&&args[3].equals("assign-sheet")){assignCharacterBoss(sender,player,dungeon,room,args[4]);return;}if(args.length>=5&&args[3].equals("mob")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living boss type.");return;}var location=player.getLocation();observedSave(sender,"administrator_native_boss",()->directory.setBoss(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch())).whenComplete((value,failure)->saved(sender,failure,"Configured native boss "+type.name().toLowerCase(Locale.ROOT)+"."));return;}if(args.length==4&&args[3].equals("clear")){observedSave(sender,"administrator_native_boss_clear",()->directory.clearBoss(dungeon,room.id())).whenComplete((value,failure)->saved(sender,failure,"Cleared the room boss."));return;}usage(sender);}
|
||||
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var sheetIdentity=sheets.identity(held).orElse(null);var character=sheetIdentity==null?null:sheetIdentity.character();if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}var audit=characterBossEvents;var request=audit==null?null:audit.request(sender);characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();if(!sheetIdentity.generation().equals(expected.sheetGeneration())||(sheetIdentity.legacy()&&expected.sheetGeneration().signum()!=0)){respond(sender,()->sender.sendMessage("Character boss assignment refused: that sheet generation is no longer authorized."));return;}var assignment=directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier);assignment.whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.custodySaved(audit.assignment(request,publication,dungeon,room.id(),custody)));}catch(RuntimeException ignored){/* Saved custody observation never gates native revalidation or consumption. */}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){var rollback=directory.cancelAssignment(dungeon,custody.operation());rollback.whenComplete((applied,rollbackFailure)->{if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.rolledBack(audit.assignment(request,publication,dungeon,room.id(),custody),rollbackFailure==null&&Boolean.TRUE.equals(applied),DungeonDirectory.publication(rollback).orElse(null),"saved_character_changed"));}catch(RuntimeException ignored){/* Original rollback observation cannot change character/custody authority. */}respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing."));});return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||!sheets.authorizes(current,expected)){var rollback=directory.cancelAssignment(dungeon,custody.operation());rollback.whenComplete((applied,rollbackFailure)->{if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.rolledBack(audit.assignment(request,publication,dungeon,room.id(),custody),rollbackFailure==null&&Boolean.TRUE.equals(applied),DungeonDirectory.publication(rollback).orElse(null),"held_sheet_changed"));}catch(RuntimeException ignored){/* Original rollback observation cannot change inventory/custody authority. */}respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing."));});return;}player.getInventory().setItemInMainHand(null);if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.assigned(audit.assignment(request,publication,dungeon,room.id(),custody)));}catch(RuntimeException ignored){/* Optional observation cannot interrupt physical consumption. */}sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
|
||||
private void bossCommand(CommandSender sender,String[] args){if(args.length!=4||!args[2].equals("recover-sheet")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=id(args[3]);if(dungeon==null){usage(sender);return;}recoverCharacterBoss(sender,player,dungeon);}
|
||||
private void recoverCharacterBoss(CommandSender sender,Player player,UUID dungeon){var definition=directory.find(dungeon).orElse(null);var custody=directory.custodyForDungeon(dungeon).orElse(null);if(definition==null||custody==null){sender.sendMessage("Dungeon character boss custody was not found.");return;}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeon);boolean accepted=adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon));if(definition.enabled()||offered||accepted||controller.beingEdited(dungeon)||controller.editing(player.getUniqueId()).isPresent()||controller.occupied(dungeon)){sender.sendMessage("Recovery requires a disabled, closed, unedited and unoccupied dungeon with no accepted quests.");return;}var physical=plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents()).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(custody.character().id()::equals).isPresent()).map(item->Map.entry(holder,item))).toList();if(physical.size()>1||physical.size()==1&&physical.getFirst().getKey()!=player){sender.sendMessage("Recovery refused because existing physical-sheet custody is ambiguous.");return;}boolean delivered=!physical.isEmpty()||custody.recovery()!=null&&Arrays.stream(player.getInventory().getContents()).anyMatch(item->sheets.hasRecovery(item,custody.recovery().operation()));if(!delivered&&player.getInventory().firstEmpty()<0){sender.sendMessage("One empty inventory slot is required before recovery.");return;}directory.prepareRecovery(dungeon,player.getUniqueId()).whenComplete((prepared,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Character boss recovery could not be prepared; custody is retained.");return;}var operation=prepared.recovery().operation();org.bukkit.inventory.ItemStack sheet=null;for(var item:player.getInventory().getContents())if(sheets.hasRecovery(item,operation)||sheets.identify(item).filter(prepared.character().id()::equals).isPresent()){sheet=item;break;}if(sheet==null){int slot=player.getInventory().firstEmpty();if(slot<0){sender.sendMessage("Character boss recovery is prepared, but one empty inventory slot is required.");return;}sheet=sheets.create(prepared.character());sheets.markRecovery(sheet,operation);player.getInventory().setItem(slot,sheet);}var item=sheet;directory.completeRecovery(dungeon,operation).whenComplete((completed,completeFailure)->respond(sender,()->{if(completeFailure!=null||!completed){sender.sendMessage("Recovery sheet delivery is retained, but durable custody completion must be retried.");return;}sheets.clearRecovery(item);sender.sendMessage("Recovered character sheet for "+prepared.character().name()+" from dungeon "+dungeon+"; configure another boss before enabling it.");}));}));}
|
||||
private void recoverCharacterBoss(CommandSender sender,Player player,UUID dungeon){var definition=directory.find(dungeon).orElse(null);var custody=directory.custodyForDungeon(dungeon).orElse(null);if(definition==null||custody==null){sender.sendMessage("Dungeon character boss custody was not found.");return;}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeon);boolean accepted=adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon));if(definition.enabled()||offered||accepted||controller.beingEdited(dungeon)||controller.editing(player.getUniqueId()).isPresent()||controller.occupied(dungeon)){sender.sendMessage("Recovery requires a disabled, closed, unedited and unoccupied dungeon with no accepted quests.");return;}var physical=plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents()).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(custody.character().id()::equals).isPresent()).map(item->Map.entry(holder,item))).toList();if(physical.size()>1||physical.size()==1&&physical.getFirst().getKey()!=player){sender.sendMessage("Recovery refused because existing physical-sheet custody is ambiguous.");return;}boolean delivered=!physical.isEmpty()||custody.recovery()!=null&&Arrays.stream(player.getInventory().getContents()).anyMatch(item->sheets.hasRecovery(item,custody.recovery().operation()));if(!delivered&&player.getInventory().firstEmpty()<0){sender.sendMessage("One empty inventory slot is required before recovery.");return;}var audit=characterBossEvents;var request=audit==null?null:audit.request(sender);var preparation=directory.prepareRecovery(dungeon,player.getUniqueId());preparation.whenComplete((prepared,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Character boss recovery could not be prepared; custody is retained.");return;}var operation=prepared.recovery().operation();org.bukkit.inventory.ItemStack sheet=null;for(var item:player.getInventory().getContents())if(sheets.hasRecovery(item,operation)||sheets.identify(item).filter(prepared.character().id()::equals).isPresent()){sheet=item;break;}if(sheet==null){int slot=player.getInventory().firstEmpty();if(slot<0){sender.sendMessage("Character boss recovery is prepared, but one empty inventory slot is required.");return;}sheet=sheets.create(prepared.character());sheets.markRecovery(sheet,operation);player.getInventory().setItem(slot,sheet);}var item=sheet;games.dmg.fantasystick.events.CharacterBossEventBridge.Facts deliveryFacts=null;if(audit!=null)try{var view=DungeonDirectory.publication(preparation).flatMap(publication->publication.after().dungeons().stream().filter(value->value.id().equals(dungeon)).findFirst()).orElse(definition);var originalRoom=view.rooms().stream().filter(value->value.boss()!=null&&value.boss().character()!=null&&value.boss().character().operation().equals(prepared.operation())).findFirst().orElseThrow();deliveryFacts=audit.custody(request,view,originalRoom.id(),prepared);}catch(RuntimeException ignored){/* Delivery does not depend on optional metadata. */}var facts=deliveryFacts;var completion=directory.completeRecovery(dungeon,operation);completion.whenComplete((completed,completeFailure)->{if(completeFailure==null&&Boolean.TRUE.equals(completed)&&facts!=null)try{DungeonDirectory.publication(completion).ifPresent(publication->audit.recovered(facts,operation,publication,"exact_authorized_sheet_delivered"));}catch(RuntimeException ignored){/* Durable completion stays authoritative independently of feedback. */}if(facts!=null&&(completeFailure!=null||!Boolean.TRUE.equals(completed)))try{audit.recoveryPending(facts,operation,completeFailure!=null?"failed_final_source_save":"completion_not_acknowledged");}catch(RuntimeException ignored){/* Pending recovery observation cannot change custody or native delivery. */}respond(sender,()->{if(completeFailure!=null||!completed){sender.sendMessage("Recovery sheet delivery is retained, but durable custody completion must be retried.");return;}sheets.clearRecovery(item);sender.sendMessage("Recovered character sheet for "+prepared.character().name()+" from dungeon "+dungeon+"; configure another boss before enabling it.");});});}));}
|
||||
private void teleport(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Dungeon teleport requires a player.");return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var dungeon=directory.find(id).orElse(null);if(dungeon==null){sender.sendMessage("Dungeon not found.");return;}teleports.teleport(sender,"Dungeon",target(dungeon),()->directory.find(id).map(DungeonCommands::target));}
|
||||
private static AdventureAdminTeleport.Target target(DungeonState.Dungeon dungeon){var entrance=dungeon.entrance();return new AdventureAdminTeleport.Target(dungeon.id(),dungeon.name(),"dungeon entrance",dungeon.world(),entrance.x(),entrance.y(),entrance.z(),entrance.yaw(),entrance.pitch(),false);}
|
||||
private void enable(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.enable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Enabled dungeon "+id+" for later adventure catalogs.":"Dungeon enable refused: validate completion reward, rooms, boss, overlap and persistence.")));}
|
||||
private void disable(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.disable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon disable failed; retained state is authoritative.":value?"Disabled and closed dungeon "+id+".":"Dungeon not found.")));}
|
||||
private void open(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.open(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Opened dungeon "+id+" in generation "+value.revision()+".":"Dungeon open refused: it is invalid, disabled, defeated or the offer limit was reached.")));}
|
||||
private void close(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.closeOffer(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon close failed; retained state is authoritative.":value?"Closed dungeon "+id+" for this generation.":"Dungeon was not open.")));}
|
||||
private void enable(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}observedSave(sender,"administrator_enable",()->controller.enable(id)).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Enabled dungeon "+id+" for later adventure catalogs.":"Dungeon enable refused: validate completion reward, rooms, boss, overlap and persistence.")));}
|
||||
private void disable(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var request=request(sender);controller.disableWithEvidence(id,publication->{if(request.events()!=null)try{request.events().definitionSaved(request.operation(),request.actor(),publication,"administrator_disable_saved");}catch(RuntimeException ignored){}}).whenComplete((value,failure)->{respond(sender,()->sender.sendMessage(failure!=null?"Dungeon disable failed; retained state is authoritative.":value.applied()?"Disabled and closed dungeon "+id+".":"Dungeon not found."));});}
|
||||
private void open(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var request=request(sender);controller.openWithEvidence(id).whenComplete((value,failure)->{if(failure==null&&value.publication().changed()&&request.events()!=null)try{request.events().offer("dungeon.offer.opened",request.operation(),request.actor(),value.dungeon(),value.publication().after(),"administrator_open");}catch(RuntimeException ignored){}respond(sender,()->sender.sendMessage(failure==null?"Opened dungeon "+id+" in generation "+value.publication().after().revision()+".":"Dungeon open refused: it is invalid, disabled, defeated or the offer limit was reached."));});}
|
||||
private void close(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var request=request(sender);var definition=directory.find(id).orElse(null);var catalog=adventures.snapshot().catalog();controller.closeOffer(id).whenComplete((value,failure)->{if(failure==null&&Boolean.TRUE.equals(value)&&definition!=null&&catalog!=null&&request.events()!=null)try{request.events().offer("dungeon.offer.closed",request.operation(),request.actor(),definition,catalog,"administrator_close");}catch(RuntimeException ignored){}respond(sender,()->sender.sendMessage(failure!=null?"Dungeon close failed; retained state is authoritative.":value?"Closed dungeon "+id+" for this generation.":"Dungeon was not open."));});}
|
||||
private record AuditRequest(games.dmg.fantasystick.events.DungeonAuditEventBridge events,games.dmg.fantasystick.events.CampEventBridge.Actor actor,UUID operation){}
|
||||
private AuditRequest request(CommandSender sender){var events=ownerEvents;return new AuditRequest(events,events==null?null:events.actor(sender),UUID.randomUUID());}
|
||||
private <T>java.util.concurrent.CompletionStage<T> observedSave(CommandSender sender,String reason,java.util.function.Supplier<? extends java.util.concurrent.CompletionStage<T>> work){return observedSave(request(sender),reason,work);}
|
||||
private <T>java.util.concurrent.CompletionStage<T> observedSave(AuditRequest request,String reason,java.util.function.Supplier<? extends java.util.concurrent.CompletionStage<T>> work){var source=work.get();return source.whenComplete((value,failure)->{if(failure==null&&request.events()!=null)try{DungeonDirectory.publication(source).ifPresent(publication->request.events().definitionSaved(request.operation(),request.actor(),publication,reason));}catch(RuntimeException ignored){/* Audit is never authoring authority. */}});}
|
||||
public List<String> complete(CommandSender sender,String[] args){if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==1)return matching(List.of("dungeon"),args[0]);if(args.length==2&&args[0].equals("dungeon"))return matching(sender instanceof Player?List.of("boss","close","create","disable","edit","enable","entrance","list","mode","open","remove","reward","room","teleport"):List.of("close","disable","enable","list","mode","open","remove","reward"),args[1]);if(!args[0].equals("dungeon"))return List.of();if(args.length==3&&args[1].equals("boss"))return matching(List.of("recover-sheet"),args[2]);if(args.length==4&&args[1].equals("boss")&&args[2].equals("recover-sheet"))return matching(directory.snapshot().dungeons().stream().filter(value->recoverable(value.id())).map(value->value.id().toString()).toList(),args[3]);if(args.length==3&&args[1].equals("create"))return matching(List.of("--mode"),args[2]);if(args.length==4&&args[1].equals("create")&&args[2].equals("--mode"))return matching(modeValues(),args[3]);if(args.length==3&&Set.of("close","disable","enable","mode","open","remove","reward","teleport").contains(args[1]))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&args[1].equals("mode"))return matching(modeValues(),args[3]);if((args.length==4||args.length==5)&&args[1].equals("reward")){UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null)return List.of();var values=new ArrayList<>(List.of("0","10","100","2147483647"));values.add((args.length==4?current.completionReward().xp():current.completionReward().coins()).toString());return matching(values,args[args.length-1]);}if(args.length==4&&Set.of("close","disable","remove").contains(args[1]))return matching(List.of("confirm"),args[3]);if(args.length==3&&args[1].equals("edit"))return matching(List.of("done","start"),args[2]);if(args.length==4&&args[1].equals("edit")&&args[2].equals("start"))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[3]);if(args.length==5&&args[1].equals("edit")&&args[2].equals("start"))return matching(List.of("confirm"),args[4]);if(args.length==3&&args[1].equals("entrance"))return matching(List.of("set"),args[2]);if(args.length==3&&args[1].equals("room"))return matching(List.of("boss","create","effect","list","mob","remove"),args[2]);if(args.length==4&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("corner","radius"),args[3]);if(args.length>=5&&args.length<=7&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("1","3","8","128","256"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("remove")&&sender instanceof Player player){var id=controller.editing(player.getUniqueId()).orElse(null);return id==null?List.of():matching(directory.find(id).map(DungeonState.Dungeon::rooms).orElse(List.of()).stream().map(v->v.id().toString()).toList(),args[3]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("mob"))return matching(List.of("add","list","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("add"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.mobs().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("effect"))return matching(List.of("add","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("both","mobs","players"),args[4]);if(args.length==6&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(effectNames(),args[5]);if(args.length==7&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("1","2","64","256"),args[6]);if(args.length==8&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("false","true"),args[7]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.effects().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("boss"))return matching(List.of("assign-sheet","clear","mob"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("mob"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("assign-sheet"))return matching(List.of("0.1","1","1.5","10","100"),args[4]);return List.of();}
|
||||
private long physicalCopies(UUID character){return plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents())).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(character::equals).isPresent()).mapToLong(org.bukkit.inventory.ItemStack::getAmount).sum();}
|
||||
private boolean recoverable(UUID dungeon){var definition=directory.find(dungeon).orElse(null);var catalog=adventures.snapshot().catalog();return definition!=null&&!definition.enabled()&&directory.custodyForDungeon(dungeon).isPresent()&&!controller.beingEdited(dungeon)&&!controller.occupied(dungeon)&&(catalog==null||!adventures.offerOpen(dungeon))&&adventures.active().stream().noneMatch(quest->quest.site().equals(dungeon));}
|
||||
|
||||
@@ -32,45 +32,89 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
private record Edit(UUID dungeon,Map<UUID,Color> colors){}
|
||||
private static final String ENEMY="dungeon_enemy",PROJECTILE="dungeon_projectile";
|
||||
private record AttemptKey(UUID dungeon,UUID room){}
|
||||
private record PendingKill(UUID dungeon,UUID room,UUID generation,UUID slot){}
|
||||
private static final class Attempt {final UUID dungeon,room,generation;final Map<UUID,UUID> entities=new HashMap<>();final Set<UUID> represented=new HashSet<>();final Map<UUID,Double> threat=new HashMap<>();final Map<String,Long> cooldowns=new HashMap<>();final Map<String,Long> effects=new HashMap<>();UUID boss,target;long lastCombat=System.nanoTime();Attempt(UUID dungeon,UUID room,UUID generation){this.dungeon=dungeon;this.room=room;this.generation=generation;}}
|
||||
private games.dmg.fantasystick.events.DungeonAuditEventBridge ownerEvents;
|
||||
private games.dmg.fantasystick.events.CharacterBossEventBridge characterBossEvents;
|
||||
private final Map<UUID,games.dmg.fantasystick.events.CharacterBossEventBridge.Facts> characterBossShellFacts=new HashMap<>();
|
||||
private CharacterBossEffectAudit characterBossEffects;
|
||||
private record ObservedCharacterDamage(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,EntityDamageByEntityEvent event){}
|
||||
private static final class CharacterCast {
|
||||
final games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts;final UUID shell;final List<ObservedCharacterDamage> damage=new ArrayList<>();final List<CharacterBossEffectAudit.NativeCall> effects=new ArrayList<>();String nativeOutcome;
|
||||
CharacterCast(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,UUID shell){this.facts=facts;this.shell=shell;}
|
||||
}
|
||||
private final ThreadLocal<Deque<CharacterCast>> characterCasts=ThreadLocal.withInitial(ArrayDeque::new);
|
||||
private record CharacterPotion(CharacterCast cast,games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,LivingEntity target,PotionEffect requested,PotionEffect prior,List<EntityPotionEffectEvent> nativeEvents){}
|
||||
private final ThreadLocal<Deque<CharacterPotion>> characterPotions=ThreadLocal.withInitial(ArrayDeque::new);
|
||||
private record NativeKnockback(org.bukkit.event.entity.EntityKnockbackEvent event) {}
|
||||
private final ThreadLocal<List<NativeKnockback>> characterMeleeKnockback=new ThreadLocal<>();
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void characterNativeKnockback(org.bukkit.event.entity.EntityKnockbackEvent event){
|
||||
try{var calls=characterMeleeKnockback.get();if(calls!=null)calls.add(new NativeKnockback(event));}catch(RuntimeException unavailable){/* Native knockback remains authoritative. */}
|
||||
}
|
||||
public void attachCharacterBossEvents(games.dmg.fantasystick.events.CharacterBossEventBridge events){characterBossEvents=Objects.requireNonNull(events);characterBossEffects=new CharacterBossEffectAudit(events,nextTick);}
|
||||
public void attachOwnerEvents(games.dmg.fantasystick.events.DungeonAuditEventBridge events){ownerEvents=Objects.requireNonNull(events);}
|
||||
private record PendingKill(UUID entity,UUID dungeon,UUID room,UUID generation,UUID slot,games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts observation){}
|
||||
private static final class Attempt {final UUID dungeon,room,generation;final Map<UUID,UUID> entities=new HashMap<>();final Set<UUID> represented=new HashSet<>();final Map<UUID,Double> threat=new HashMap<>();final Map<String,Long> cooldowns=new HashMap<>();final Map<String,Long> effects=new HashMap<>();UUID boss,target,defeatedBoss;games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts observation,deathObservation;games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterDeathObservation;Map<String,String> defeatAudit=Map.of();long lastCombat=System.nanoTime();Attempt(UUID dungeon,UUID room,UUID generation){this.dungeon=dungeon;this.room=room;this.generation=generation;}}
|
||||
private record Context(UUID character,UUID party,Set<UUID> members){}
|
||||
private record OwnedEffect(UUID dungeon,UUID player,PotionEffectType type,int amplifier,PotionEffect prior){}
|
||||
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;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 AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;private final games.dmg.fantasystick.presentation.ParticleDelivery particles;private FishingController fishing;
|
||||
private record OwnedEffect(UUID dungeon,UUID player,PotionEffectType type,int amplifier,PotionEffect prior,games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts observation){}
|
||||
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;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 AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;private final games.dmg.fantasystick.presentation.ParticleDelivery particles;private FishingController fishing;private games.dmg.fantasystick.events.DungeonEventBridge auditEvents;
|
||||
private final NamespacedKey typeKey,dungeonKey,roomKey,slotKey,generationKey,bossKey;
|
||||
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Map<UUID,PendingKill> pendingKills=new ConcurrentHashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
|
||||
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Map<UUID,PendingKill> pendingKills=new ConcurrentHashMap<>();private final Map<UUID,UUID> pendingBosses=new ConcurrentHashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,new AbilityRegistry(),new PassiveRegistry(),games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,abilities,passives,games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,abilities,passives,recoverySettings,recoveryBonus,healthStealHealing,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.plugin=Objects.requireNonNull(plugin);this.dungeons=Objects.requireNonNull(dungeons);this.adventures=Objects.requireNonNull(adventures);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.abilities=Objects.requireNonNull(abilities);this.passives=Objects.requireNonNull(passives);this.recoverySettings=Objects.requireNonNull(recoverySettings);this.recoveryBonus=Objects.requireNonNull(recoveryBonus);this.healthStealHealing=Objects.requireNonNull(healthStealHealing);this.particles=Objects.requireNonNull(particles);typeKey=new NamespacedKey(plugin,"managed_npc_type");dungeonKey=new NamespacedKey(plugin,"dungeon_id");roomKey=new NamespacedKey(plugin,"dungeon_room_id");slotKey=new NamespacedKey(plugin,"dungeon_spawn_id");generationKey=new NamespacedKey(plugin,"dungeon_generation_id");bossKey=new NamespacedKey(plugin,"dungeon_boss");}
|
||||
public void attachEvents(games.dmg.fantasystick.events.DungeonEventBridge events){this.auditEvents=Objects.requireNonNull(events);}
|
||||
public void registerFishing(FishingController fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing already attached or dungeon controller closed");this.fishing=Objects.requireNonNull(fishing);}
|
||||
public void install(){synchronizeEnabled();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();pulse=plugin.getServer().getScheduler().runTaskTimer(plugin,this::tick,1,1);}
|
||||
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();if(data==null)return false;var type=data.get(typeKey,PersistentDataType.STRING);return ENEMY.equals(type)||PROJECTILE.equals(type);}
|
||||
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){if(!managedEnemy(entity))return Optional.empty();UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey),generation=id(entity,generationKey);if(dungeon==null||room==null||generation==null)return Optional.empty();var attempt=attempts.get(new AttemptKey(dungeon,room));if(attempt!=null&&!attempt.generation.equals(generation))return Optional.empty();UUID slot=id(entity,slotKey);if(slot==null&&(!Boolean.TRUE.equals(entity.getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN))||dungeons.room(dungeon,room).map(value->value.boss()==null).orElse(true)))return Optional.empty();if(slot!=null&&(dungeons.room(dungeon,room).stream().flatMap(value->value.mobs().stream()).noneMatch(value->value.id().equals(slot))||dungeons.runView(dungeon,generation).map(run->run.defeatedSlots().contains(slot)||run.defeated()).orElse(true)))return Optional.empty();String encounter=slot==null?"boss":slot.toString();UUID source=UUID.nameUUIDFromBytes((dungeon+":"+room+":"+generation+":"+encounter).getBytes(java.nio.charset.StandardCharsets.UTF_8));if(slot!=null&&attempt==null&&!pendingKills.containsKey(source))return Optional.empty();return Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,source));}
|
||||
public void settleKill(games.dmg.fantasystick.progression.ManagedEncounterOrigin origin){if(origin.kind()!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON)return;var pending=pendingKills.remove(origin.source());if(pending==null)return;dungeons.defeatSlot(pending.dungeon(),pending.generation(),pending.slot()).whenComplete((run,failure)->schedule(()->{if(failure!=null)return;var room=dungeons.room(pending.dungeon(),pending.room()).orElse(null);var attempt=attempts.get(new AttemptKey(pending.dungeon(),pending.room()));var dungeon=dungeons.find(pending.dungeon()).orElse(null);if(room!=null&&attempt!=null&&dungeon!=null&&room.boss()==null&&room.mobs().stream().allMatch(value->run.defeatedSlots().contains(value.id())))clearRoom(dungeon,room,attempt,false);}));}
|
||||
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){if(!managedEnemy(entity))return Optional.empty();UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey),generation=id(entity,generationKey);if(dungeon==null||room==null||generation==null)return Optional.empty();var attempt=attempts.get(new AttemptKey(dungeon,room));if(attempt!=null&&!attempt.generation.equals(generation))return Optional.empty();UUID slot=id(entity,slotKey);if(slot==null&&(!Boolean.TRUE.equals(entity.getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN))||dungeons.room(dungeon,room).map(value->value.boss()==null).orElse(true)))return Optional.empty();if(slot!=null&&(dungeons.room(dungeon,room).stream().flatMap(value->value.mobs().stream()).noneMatch(value->value.id().equals(slot))||dungeons.runView(dungeon,generation).map(run->run.defeatedSlots().contains(slot)||run.defeated()).orElse(true)))return Optional.empty();String encounter=slot==null?"boss":slot.toString();UUID source=UUID.nameUUIDFromBytes((dungeon+":"+room+":"+generation+":"+encounter).getBytes(java.nio.charset.StandardCharsets.UTF_8));if(slot!=null){var pending=pendingKills.get(source);if(!slot.equals(attempt==null?null:attempt.entities.get(entity.getUniqueId()))&&(pending==null||!entity.getUniqueId().equals(pending.entity())))return Optional.empty();}else if(!entity.getUniqueId().equals(attempt==null?null:attempt.boss)&&!entity.getUniqueId().equals(pendingBosses.get(source)))return Optional.empty();return Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,source));}
|
||||
public Map<String,Object> killEvidence(LivingEntity entity){
|
||||
var origin=encounterOrigin(entity).orElse(null);
|
||||
if(origin==null)return Map.of();
|
||||
UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey),generation=id(entity,generationKey);
|
||||
if(dungeon==null||room==null||generation==null)return Map.of();
|
||||
var definition=dungeons.find(dungeon).orElse(null);
|
||||
var roomDefinition=dungeons.room(dungeon,room).orElse(null);
|
||||
if(definition==null||roomDefinition==null)return Map.of();
|
||||
var slot=id(entity,slotKey);
|
||||
var result=new LinkedHashMap<String,Object>();
|
||||
result.put("managed_source_id",origin.source().toString());
|
||||
result.put("dungeon_id",dungeon.toString());result.put("dungeon_name",definition.name());
|
||||
result.put("room_id",room.toString());result.put("room_name",roomDefinition.displayName());
|
||||
result.put("generation_id",generation.toString());
|
||||
result.put("spawn_slot_or_boss",slot==null?"boss":slot.toString());
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
public void settleKill(games.dmg.fantasystick.progression.ManagedEncounterOrigin origin){if(origin.kind()!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON)return;pendingBosses.remove(origin.source());var pending=pendingKills.remove(origin.source());if(pending==null)return;var saved=dungeons.defeatSlot(pending.dungeon(),pending.generation(),pending.slot());saved.whenComplete((run,failure)->schedule(()->{if(failure!=null)return;if(DungeonDirectory.publication(saved).isPresent())observe("dungeon.entity.defeated",pending.observation(),Map.of("settlement_status","acknowledged_slot_save","defeated_slot",true,"reason","native_kill_settled"));var room=dungeons.room(pending.dungeon(),pending.room()).orElse(null);var attempt=attempts.get(new AttemptKey(pending.dungeon(),pending.room()));var dungeon=dungeons.find(pending.dungeon()).orElse(null);if(room!=null&&attempt!=null&&dungeon!=null&&room.boss()==null&&room.mobs().stream().allMatch(value->run.defeatedSlots().contains(value.id())))clearRoom(dungeon,room,attempt,false);}));}
|
||||
private void synchronizeEnabled(){for(var site:adventures.snapshot().sites())if(site.type()==AdventureQuestState.SiteType.DUNGEON)adventures.removeDungeonSite(site.id());}
|
||||
public CompletionStage<EditStatus> start(Player player,UUID dungeonId,boolean confirm){if(closed)return CompletableFuture.completedFuture(EditStatus.BUSY);UUID playerId=player.getUniqueId();var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)return CompletableFuture.completedFuture(EditStatus.NOT_FOUND);synchronized(this){if(playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return CompletableFuture.completedFuture(EditStatus.BUSY);}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeonId);boolean work=offered||adventures.active().stream().anyMatch(q->q.site().equals(dungeonId))||dungeons.snapshot().runs().stream().anyMatch(run->run.dungeon().equals(dungeonId));if(work&&!confirm)return CompletableFuture.completedFuture(EditStatus.CONFIRM_REQUIRED);CompletionStage<?> transition=settleAwards().thenCompose(ignored->dungeons.disable(dungeonId)).thenCompose(ignored->adventures.closeOffer(dungeonId)).thenCompose(ignored->adventures.removeDungeonSite(dungeonId)).thenCompose(ignored->cleanupOnMain(dungeonId));return transition.thenApply(ignored->{synchronized(this){if(closed||playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return EditStatus.BUSY;var colors=new HashMap<UUID,Color>();for(var room:dungeons.find(dungeonId).orElseThrow().rooms())colors.put(room.id(),randomColor());playerEdits.put(playerId,new Edit(dungeonId,colors));dungeonEditors.put(dungeonId,playerId);return EditStatus.STARTED;}});}
|
||||
public record EditPublication(EditStatus status,DungeonState.Dungeon dungeon){}
|
||||
public CompletionStage<EditStatus> start(Player player,UUID dungeonId,boolean confirm){return startWithEvidence(player,dungeonId,confirm).thenApply(EditPublication::status);}
|
||||
public CompletionStage<EditPublication> startWithEvidence(Player player,UUID dungeonId,boolean confirm){if(closed)return CompletableFuture.completedFuture(new EditPublication(EditStatus.BUSY,null));UUID playerId=player.getUniqueId();var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)return CompletableFuture.completedFuture(new EditPublication(EditStatus.NOT_FOUND,null));synchronized(this){if(playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return CompletableFuture.completedFuture(new EditPublication(EditStatus.BUSY,null));}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeonId);boolean work=offered||adventures.active().stream().anyMatch(q->q.site().equals(dungeonId))||dungeons.snapshot().runs().stream().anyMatch(run->run.dungeon().equals(dungeonId));if(work&&!confirm)return CompletableFuture.completedFuture(new EditPublication(EditStatus.CONFIRM_REQUIRED,null));CompletionStage<?> transition=settleAwards().thenCompose(ignored->dungeons.disable(dungeonId)).thenCompose(ignored->adventures.closeOffer(dungeonId)).thenCompose(ignored->adventures.removeDungeonSite(dungeonId)).thenCompose(ignored->cleanupOnMain(dungeonId));return transition.thenApply(ignored->{synchronized(this){if(closed||playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return new EditPublication(EditStatus.BUSY,null);var leased=dungeons.find(dungeonId).orElseThrow();var colors=new HashMap<UUID,Color>();for(var room:leased.rooms())colors.put(room.id(),randomColor());playerEdits.put(playerId,new Edit(dungeonId,colors));dungeonEditors.put(dungeonId,playerId);return new EditPublication(EditStatus.STARTED,leased);}});}
|
||||
public synchronized boolean done(UUID player){var edit=playerEdits.remove(player);if(edit==null)return false;dungeonEditors.remove(edit.dungeon(),player);return true;}
|
||||
public synchronized Optional<UUID> editing(UUID player){var edit=playerEdits.get(player);return edit==null?Optional.empty():Optional.of(edit.dungeon());}
|
||||
public synchronized boolean beingEdited(UUID dungeon){return dungeonEditors.containsKey(dungeon);}
|
||||
public boolean occupied(UUID dungeon){var definition=dungeons.find(dungeon).orElse(null);if(definition==null)return false;return plugin.getServer().getOnlinePlayers().stream().anyMatch(player->definition.rooms().stream().anyMatch(room->inside(player.getLocation(),definition,room)));}
|
||||
public Optional<DungeonState.Room> standingRoom(Player player){var dungeon=editing(player.getUniqueId()).orElse(null);if(dungeon==null)return Optional.empty();var location=player.getLocation();return dungeons.roomAt(dungeon,location.getBlockX(),location.getBlockY(),location.getBlockZ());}
|
||||
public CompletionStage<DungeonState.Dungeon> enable(UUID id){synchronized(this){if(dungeonEditors.containsKey(id))return CompletableFuture.failedFuture(new IllegalStateException("Dungeon is being edited"));}return dungeons.enable(id,List.of());}
|
||||
public CompletionStage<Boolean> disable(UUID id){return settleAwards().thenCompose(ignored->dungeons.disable(id)).thenCompose(value->adventures.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->cleanupOnMain(id)).thenApply(ignored->value.isPresent()));}
|
||||
public CompletionStage<AdventureQuestState.Catalog> open(UUID id){var dungeon=dungeons.find(id).filter(DungeonState.Dungeon::enabled).orElseThrow(()->new IllegalArgumentException("Dungeon is not enabled"));var catalog=adventures.snapshot().catalog();if(catalog!=null&&dungeons.runView(id,catalog.revision()).map(DungeonState.Run::defeated).orElse(false))return CompletableFuture.failedFuture(new IllegalArgumentException("Dungeon is defeated"));return adventures.ensureToday().thenCompose(current->adventures.appendOffer(dungeon.id()));}
|
||||
public record DisableResult(boolean applied,DungeonDirectory.Publication publication){}
|
||||
public CompletionStage<Boolean> disable(UUID id){return disableWithEvidence(id).thenApply(DisableResult::applied);}
|
||||
public CompletionStage<DisableResult> disableWithEvidence(UUID id){return disableWithEvidence(id,ignored->{});}
|
||||
public CompletionStage<DisableResult> disableWithEvidence(UUID id,Consumer<DungeonDirectory.Publication> saved){return settleAwards().thenCompose(ignored->{var source=dungeons.disable(id);return source.thenCompose(value->{var publication=DungeonDirectory.publication(source).orElse(null);if(publication!=null)try{saved.accept(publication);}catch(RuntimeException unavailable){/* Saved source authority never depends on its observer. */}return adventures.closeOffer(id).thenCompose(closedOffer->adventures.removeDungeonSite(id)).thenCompose(removedSite->cleanupOnMain(id)).thenApply(cleaned->new DisableResult(value.isPresent(),publication));});});}
|
||||
public record OpenResult(DungeonState.Dungeon dungeon,AdventureQuestDirectory.OfferPublication publication){}
|
||||
public CompletionStage<AdventureQuestState.Catalog> open(UUID id){return openWithEvidence(id).thenApply(value->value.publication().after());}
|
||||
public CompletionStage<OpenResult> openWithEvidence(UUID id){var dungeon=dungeons.find(id).filter(DungeonState.Dungeon::enabled).orElseThrow(()->new IllegalArgumentException("Dungeon is not enabled"));var catalog=adventures.snapshot().catalog();if(catalog!=null&&dungeons.runView(id,catalog.revision()).map(DungeonState.Run::defeated).orElse(false))return CompletableFuture.failedFuture(new IllegalArgumentException("Dungeon is defeated"));return adventures.ensureToday().thenCompose(current->adventures.appendOfferWithEvidence(dungeon.id())).thenApply(publication->new OpenResult(dungeon,publication));}
|
||||
public CompletionStage<Boolean> closeOffer(UUID id){var catalog=adventures.snapshot().catalog();boolean defeated=catalog!=null&&dungeons.runView(id,catalog.revision()).map(DungeonState.Run::defeated).orElse(false);return settleAwards().thenCompose(ignored->adventures.closeOffer(id)).thenCompose(changed->{if(catalog==null)return CompletableFuture.completedFuture(changed);CompletionStage<?> progress=defeated?CompletableFuture.completedFuture(false):dungeons.clearRun(id,catalog.revision());return progress.thenCompose(ignored->cleanupOnMain(id)).thenApply(ignored->changed);});}
|
||||
private CompletableFuture<Void> settleAwards(){return CompletableFuture.allOf(inFlight.stream().map(future->future.handle((ignored,failure)->null)).toArray(CompletableFuture[]::new));}
|
||||
private CompletionStage<Void> cleanupOnMain(UUID dungeon){var result=new CompletableFuture<Void>();if(closed){result.completeExceptionally(new IllegalStateException("Dungeon controller closed"));return result;}try{nextTick.accept(()->{if(closed){result.completeExceptionally(new IllegalStateException("Dungeon controller closed"));return;}cleanupDungeon(dungeon);synchronized(this){var editor=dungeonEditors.remove(dungeon);if(editor!=null)playerEdits.remove(editor);}result.complete(null);});}catch(RuntimeException failure){result.completeExceptionally(failure);}return result;}
|
||||
public void definitionChanged(UUID dungeon){synchronized(this){var editor=dungeonEditors.get(dungeon);if(editor==null)return;var edit=playerEdits.get(editor);if(edit==null)return;for(var room:dungeons.find(dungeon).map(DungeonState.Dungeon::rooms).orElse(List.of()))edit.colors().computeIfAbsent(room.id(),ignored->randomColor());}}
|
||||
private void tick(){if(closed)return;if(++ticks%10==0)showBorders();runtime();}
|
||||
private void runtime(){var catalog=adventures.snapshot().catalog();if(catalog==null||adventures.snapshot().reset()!=null){cleanupRuntime();return;}var activeDungeonIds=catalog.sites().stream().filter(adventures::offerOpen).filter(id->adventures.findSite(id).map(AdventureQuestState.Site::type).orElse(null)==AdventureQuestState.SiteType.DUNGEON).collect(java.util.stream.Collectors.toSet());for(var key:new ArrayList<>(attempts.keySet()))if(!activeDungeonIds.contains(key.dungeon()))cleanupAttempt(key);var activeEffects=new HashSet<String>();for(UUID dungeonId:activeDungeonIds){if(defeatingDungeons.contains(dungeonId))continue;var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)continue;var run=dungeons.runView(dungeonId,catalog.revision()).orElse(null);if(run==null){String key=dungeonId+":"+catalog.revision();if(startingRuns.add(key))dungeons.run(dungeonId,catalog.revision()).whenComplete((ignored,failure)->schedule(()->startingRuns.remove(key)));continue;}if(run.defeated()){cleanupDungeon(dungeonId);continue;}for(var room:dungeon.rooms()){var players=eligibleInside(dungeon,room).toList();var key=new AttemptKey(dungeonId,room.id());if(players.isEmpty()){cleanupAttempt(key);continue;}applyPlayerEffects(dungeon.id(),room,players,activeEffects);if(run.clearedRooms().contains(room.id())){cleanupAttempt(key);continue;}if(completing.contains(key))continue;var attempt=attempts.computeIfAbsent(key,ignored->new Attempt(dungeonId,room.id(),catalog.revision()));contain(dungeon,room,attempt);if(attempt.entities.isEmpty()&&attempt.boss==null)spawnRoom(dungeon,room,run,attempt);if(room.boss()!=null&&room.boss().character()!=null)driveCharacterBoss(dungeon,room,attempt,players);if(room.mobs().isEmpty()&&room.boss()==null){attempt.represented.addAll(players.stream().map(this::eligibleQuest).filter(Objects::nonNull).map(AdventureQuestState.Quest::id).toList());clearRoom(dungeon,room,attempt,false);}}}cleanupOwnedEffects(activeEffects);}
|
||||
private void runtime(){var catalog=adventures.snapshot().catalog();if(catalog==null||adventures.snapshot().reset()!=null){cleanupRuntime();return;}var activeDungeonIds=catalog.sites().stream().filter(adventures::offerOpen).filter(id->adventures.findSite(id).map(AdventureQuestState.Site::type).orElse(null)==AdventureQuestState.SiteType.DUNGEON).collect(java.util.stream.Collectors.toSet());for(var key:new ArrayList<>(attempts.keySet()))if(!activeDungeonIds.contains(key.dungeon()))cleanupAttempt(key);var activeEffects=new HashSet<String>();for(UUID dungeonId:activeDungeonIds){if(defeatingDungeons.contains(dungeonId))continue;var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)continue;var run=dungeons.runView(dungeonId,catalog.revision()).orElse(null);if(run==null){String key=dungeonId+":"+catalog.revision();if(startingRuns.add(key)){var observation=facts(dungeon,null,catalog.revision(),null,Map.of("reason","offered_runtime_initialized"));var source=dungeons.run(dungeonId,catalog.revision());source.whenComplete((ignored,failure)->{if(failure==null&&DungeonDirectory.publication(source).isPresent())observe("dungeon.run.started",observation,Map.of("run_status","persisted_undefeated"));schedule(()->startingRuns.remove(key));});}continue;}if(run.defeated()){cleanupDungeon(dungeonId);continue;}for(var room:dungeon.rooms()){var players=eligibleInside(dungeon,room).toList();var key=new AttemptKey(dungeonId,room.id());if(players.isEmpty()){cleanupAttempt(key,"no_eligible_player_in_room");continue;}applyPlayerEffects(dungeon.id(),room,players,activeEffects);if(run.clearedRooms().contains(room.id())){cleanupAttempt(key);continue;}if(completing.contains(key))continue;var attempt=attempts.computeIfAbsent(key,ignored->{var created=new Attempt(dungeonId,room.id(),catalog.revision());var trigger=players.getFirst();created.observation=facts(dungeon,room,catalog.revision(),trigger,questData(eligible(trigger,dungeonId)));observe("dungeon.room.activated",created.observation,Map.of("attempt_capture_quality","native_transient_not_persisted","reason","eligible_room_entry"));return created;});contain(dungeon,room,attempt);if(attempt.entities.isEmpty()&&attempt.boss==null)spawnRoom(dungeon,room,run,attempt);if(room.boss()!=null&&room.boss().character()!=null)driveCharacterBoss(dungeon,room,attempt,players);if(room.mobs().isEmpty()&&room.boss()==null){attempt.represented.addAll(players.stream().map(this::eligibleQuest).filter(Objects::nonNull).map(AdventureQuestState.Quest::id).toList());clearRoom(dungeon,room,attempt,false);}}}cleanupOwnedEffects(activeEffects);}
|
||||
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())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);return party==null?new Context(record.id(),null,Set.of(record.id())):new Context(record.id(),party.id(),Set.copyOf(party.characters()));}
|
||||
private AdventureQuestState.Quest eligibleQuest(Player player){var context=context(player);if(context==null)return null;var quest=adventures.activeFor(context.character(),context.party()).orElse(null);return quest!=null&&quest.snapshot().contains(context.character())?quest:null;}
|
||||
private AdventureQuestState.Quest eligible(Player player,UUID dungeon){var quest=eligibleQuest(player);return quest!=null&&quest.site().equals(dungeon)?quest:null;}
|
||||
private AdventureQuestState.Quest eligible(Player player,UUID dungeon,UUID room){var quest=eligible(player,dungeon);var definition=dungeons.find(dungeon).orElse(null);var roomDefinition=dungeons.room(dungeon,room).orElse(null);return quest!=null&&definition!=null&&roomDefinition!=null&&inside(player.getLocation(),definition,roomDefinition)?quest:null;}
|
||||
private java.util.stream.Stream<Player> eligibleInside(DungeonState.Dungeon dungeon,DungeonState.Room room){return plugin.getServer().getOnlinePlayers().stream().map(Player.class::cast).filter(player->eligible(player,dungeon.id())!=null).filter(player->inside(player.getLocation(),dungeon,room));}
|
||||
private static boolean inside(Location location,DungeonState.Dungeon dungeon,DungeonState.Room room){return location.getWorld()!=null&&location.getWorld().getUID().equals(dungeon.world())&&room.bounds().contains(location.getBlockX(),location.getBlockY(),location.getBlockZ());}
|
||||
@SuppressWarnings({"unchecked","rawtypes"}) private void spawnRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,DungeonState.Run run,Attempt attempt){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;for(var slot:room.mobs())if(!run.defeatedSlots().contains(slot.id())&&loaded(world,slot.position()))world.spawn(location(world,slot.position()),(Class)slot.type().getEntityClass(),entity->configure((Mob)entity,dungeon,room,attempt,slot.id(),false,slot.name(),room.effects()));if(room.boss()!=null&&loaded(world,room.boss().position()))world.spawn(location(world,room.boss().position()),(Class)room.boss().type().getEntityClass(),entity->configure((Mob)entity,dungeon,room,attempt,null,true,room.boss().name(),room.effects()));}
|
||||
@SuppressWarnings({"unchecked","rawtypes"}) private void spawnRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,DungeonState.Run run,Attempt attempt){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;for(var slot:room.mobs())if(!run.defeatedSlots().contains(slot.id())&&loaded(world,slot.position())){var spawned=world.spawn(location(world,slot.position()),(Class)slot.type().getEntityClass(),entity->configure((Mob)entity,dungeon,room,attempt,slot.id(),false,slot.name(),room.effects()));observeSpawn(dungeon,room,attempt,spawned,slot.id(),false);}if(room.boss()!=null&&loaded(world,room.boss().position())){var spawned=world.spawn(location(world,room.boss().position()),(Class)room.boss().type().getEntityClass(),entity->configure((Mob)entity,dungeon,room,attempt,null,true,room.boss().name(),room.effects()));if(room.boss().character()==null)observeSpawn(dungeon,room,attempt,spawned,null,true);else observeCharacterBossSpawn(dungeon,room,attempt,spawned);}}
|
||||
private void configure(Mob mob,DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,UUID slot,boolean boss,String name,List<DungeonState.Effect> effects){var data=mob.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,ENEMY);data.set(dungeonKey,PersistentDataType.STRING,dungeon.id().toString());data.set(roomKey,PersistentDataType.STRING,room.id().toString());data.set(generationKey,PersistentDataType.STRING,attempt.generation.toString());data.set(bossKey,PersistentDataType.BOOLEAN,boss);if(slot!=null)data.set(slotKey,PersistentDataType.STRING,slot.toString());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}if(mob instanceof Zombie zombie)zombie.setShouldBurnInDay(false);if(!name.isBlank()){mob.customName(net.kyori.adventure.text.Component.text(name));mob.setCustomNameVisible(true);}if(boss){attempt.boss=mob.getUniqueId();if(room.boss()!=null&&room.boss().character()!=null)configureCharacterBoss(mob,room.boss().character());}else attempt.entities.put(mob.getUniqueId(),slot);applyMobEffects(mob,effects);}
|
||||
private void configureCharacterBoss(Mob mob,DungeonState.CharacterBoss custody){var stats=custody.effectiveStats();var maximum=new java.math.BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN)).min(games.dmg.fantasystick.session.NativeHealthLimit.read()).max(java.math.BigDecimal.ONE).doubleValue();var health=mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);if(health!=null){health.setBaseValue(maximum);mob.setHealth(maximum);}var damage=mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE);if(damage!=null)damage.setBaseValue(Math.min(characterDamage(stats.get(Stat.STR)),nativeAttributeMaximum(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)));var equipment=mob.getEquipment();if(equipment!=null){var gear=custody.character().gear();equipment.setChestplate(armor(org.bukkit.inventory.EquipmentSlot.CHEST,gear.chestplate()));equipment.setLeggings(armor(org.bukkit.inventory.EquipmentSlot.LEGS,gear.leggings()));equipment.setBoots(armor(org.bukkit.inventory.EquipmentSlot.FEET,gear.boots()));equipment.setChestplateDropChance(0);equipment.setLeggingsDropChance(0);equipment.setBootsDropChance(0);}for(var id:custody.character().passives())if(passives.find(id).isPresent()){if(id.equals("speed_i"))mob.addPotionEffect(new PotionEffect(PotionEffectType.SPEED,PotionEffect.INFINITE_DURATION,0,false,false));else if(id.equals("low_light_vision"))mob.addPotionEffect(new PotionEffect(PotionEffectType.NIGHT_VISION,PotionEffect.INFINITE_DURATION,0,false,false));}}
|
||||
private static double nativeAttributeMaximum(org.bukkit.attribute.Attribute attribute){try{var handle=attribute.getClass().getMethod("getHandle").invoke(attribute);double maximum=((Number)handle.getClass().getMethod("getMaxValue").invoke(handle)).doubleValue();if(!Double.isFinite(maximum)||maximum<=0)throw new IllegalStateException("Invalid native attribute range");return maximum;}catch(ReflectiveOperationException|ClassCastException failure){throw new IllegalStateException("Cannot read native attribute range",failure);}}
|
||||
@@ -79,8 +123,8 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
private static double abilityAmount(AbilityDefinition definition,DungeonState.CharacterBoss custody,boolean healing){var stats=custody.effectiveStats();Stat stat=healing?Stat.WIS:switch(definition.id()){case"great_sword","shield_bash"->Stat.STR;case"longbow"->Stat.DEX;case"hex"->Stat.CHR;default->Stat.INT;};var base=definition.parameters().get(healing?"base-healing":"base-damage");var exact=(base==null?BigInteger.ZERO:base.toBigInteger()).add(modifier(stats.get(stat))).max(BigInteger.ZERO);return exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();}
|
||||
private static double range(AbilityDefinition definition){for(var key:List.of("range","initial-range","target-range","placement-range")){var value=definition.parameters().get(key);if(value!=null)return value.doubleValue();}return definition.id().equals("great_sword")?4d:16d;}
|
||||
private static org.bukkit.inventory.ItemStack armor(org.bukkit.inventory.EquipmentSlot slot,games.dmg.fantasystick.persistence.GearTier tier){if(tier==games.dmg.fantasystick.persistence.GearTier.NONE)return null;String material=(tier==games.dmg.fantasystick.persistence.GearTier.GOLD?"GOLDEN":tier.name())+"_"+switch(slot){case CHEST->"CHESTPLATE";case LEGS->"LEGGINGS";case FEET->"BOOTS";default->throw new IllegalArgumentException("Not armor");};return new org.bukkit.inventory.ItemStack(Material.valueOf(material));}
|
||||
private void driveCharacterBoss(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,List<Player> eligible){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;Mob boss=null;for(var entity:safe(world.getEntities()))if(entity instanceof Mob mob&&Objects.equals(attempt.boss,mob.getUniqueId())){boss=mob;break;}if(boss==null)return;var actor=boss;long now=System.nanoTime();if(attempt.effects.getOrDefault("absorption",Long.MAX_VALUE)<=now){boss.setAbsorptionAmount(0);attempt.effects.remove("absorption");}var eligibleIds=eligible.stream().map(Player::getUniqueId).collect(java.util.stream.Collectors.toSet());attempt.threat.keySet().removeIf(id->!eligibleIds.contains(id));Player target=eligible.stream().filter(player->player.getUniqueId().equals(attempt.target)&&!player.isDead()&&actor.hasLineOfSight(player)).findFirst().orElse(null);if(target==null&&!attempt.threat.isEmpty()){target=eligible.stream().filter(player->!player.isDead()&&actor.hasLineOfSight(player)&&attempt.threat.containsKey(player.getUniqueId())).max(Comparator.<Player>comparingDouble(player->attempt.threat.getOrDefault(player.getUniqueId(),0d)).thenComparing(player->player.getUniqueId().toString(),Comparator.reverseOrder())).orElse(null);attempt.target=target==null?null:target.getUniqueId();}if(target==null){regenerateCharacterBoss(boss,room.boss().character(),attempt);return;}if(boss.getTarget()!=target)boss.setTarget(target);castCharacterAbility(boss,target,eligible,room.boss().character(),attempt);}
|
||||
private void regenerateCharacterBoss(Mob boss,DungeonState.CharacterBoss custody,Attempt attempt){var settings=recoverySettings.get();long now=System.nanoTime();long ready=attempt.cooldowns.getOrDefault("recovery",attempt.lastCombat+settings.delay().toNanos());if(now<ready)return;var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);var amount=settings.amount().add(recoveryBonus.apply(custody.character()));if(maximum!=null&&amount.signum()>0&&boss.getHealth()<maximum.getValue())healBoss(boss,Math.min(Double.MAX_VALUE,amount.doubleValue()));attempt.cooldowns.put("recovery",now+settings.interval().toNanos());}
|
||||
private void driveCharacterBoss(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,List<Player> eligible){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;Mob boss=null;for(var entity:safe(world.getEntities()))if(entity instanceof Mob mob&&Objects.equals(attempt.boss,mob.getUniqueId())){boss=mob;break;}if(boss==null)return;var actor=boss;long now=System.nanoTime();if(attempt.effects.getOrDefault("absorption",Long.MAX_VALUE)<=now){boss.setAbsorptionAmount(0);attempt.effects.remove("absorption");}var eligibleIds=eligible.stream().map(Player::getUniqueId).collect(java.util.stream.Collectors.toSet());attempt.threat.keySet().removeIf(id->!eligibleIds.contains(id));Player target=eligible.stream().filter(player->player.getUniqueId().equals(attempt.target)&&!player.isDead()&&actor.hasLineOfSight(player)).findFirst().orElse(null);if(target==null&&!attempt.threat.isEmpty()){target=eligible.stream().filter(player->!player.isDead()&&actor.hasLineOfSight(player)&&attempt.threat.containsKey(player.getUniqueId())).max(Comparator.<Player>comparingDouble(player->attempt.threat.getOrDefault(player.getUniqueId(),0d)).thenComparing(player->player.getUniqueId().toString(),Comparator.reverseOrder())).orElse(null);attempt.target=target==null?null:target.getUniqueId();}if(target==null){regenerateCharacterBoss(boss,room.boss().character(),attempt);return;}var previous=boss.getTarget();if(previous!=target){var observation=characterTargetFacts(boss,target,previous,attempt);boss.setTarget(target);observeCharacterTarget(observation,boss,target);}castCharacterAbility(boss,target,eligible,room.boss().character(),attempt);}
|
||||
private void regenerateCharacterBoss(Mob boss,DungeonState.CharacterBoss custody,Attempt attempt){var settings=recoverySettings.get();long now=System.nanoTime();long ready=attempt.cooldowns.getOrDefault("recovery",attempt.lastCombat+settings.delay().toNanos());if(now<ready)return;var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);var amount=settings.amount().add(recoveryBonus.apply(custody.character()));if(maximum!=null&&amount.signum()>0&&boss.getHealth()<maximum.getValue())healBoss(boss,Math.min(Double.MAX_VALUE,amount.doubleValue()),characterPassiveHealingFacts(boss,custody,"out_of_combat_recovery",settings.amount(),amount.subtract(settings.amount())));attempt.cooldowns.put("recovery",now+settings.interval().toNanos());}
|
||||
private void castCharacterAbility(Mob boss,Player target,List<Player> eligible,DungeonState.CharacterBoss custody,Attempt attempt){
|
||||
long now=System.nanoTime();
|
||||
for(String id:custody.character().loadout()){
|
||||
@@ -93,40 +137,135 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
double range=range(definition);
|
||||
if(definition.category()==AbilityDefinition.Category.ATK&&(definition.targetPolicy()!=AbilityDefinition.TargetPolicy.AREA||id.equals("meteor"))
|
||||
&&(!boss.hasLineOfSight(target)||boss.getLocation().distanceSquared(target.getLocation())>range*range))continue;
|
||||
var observation=beginCharacterCast(boss,target,definition,custody.character().loadout().indexOf(id));
|
||||
try{
|
||||
boolean cast;
|
||||
if(id.equals("purify")){
|
||||
var harmful=boss.getActivePotionEffects().stream().filter(effect->effect.getType().getEffectCategory().name().equals("HARMFUL")).toList();
|
||||
for(var effect:harmful)boss.removePotionEffect(effect.getType());
|
||||
cast=!harmful.isEmpty();
|
||||
}else if(id.equals("hex")){
|
||||
target.addPotionEffect(new PotionEffect(PotionEffectType.WEAKNESS,Math.max(20,(int)Math.min(1200,definition.cooldown().toMillis()/50)),0,false,true),true);cast=true;
|
||||
characterPotion(target,new PotionEffect(PotionEffectType.WEAKNESS,Math.max(20,(int)Math.min(1200,definition.cooldown().toMillis()/50)),0,false,true),true);cast=true;
|
||||
}else cast=switch(definition.category()){
|
||||
case HEAL->{yield healBoss(boss,abilityAmount(definition,custody,true));}
|
||||
case BUF->{if(id.equals("quickstep")){var direction=target.getLocation().toVector().subtract(boss.getLocation().toVector());double distance=definition.parameters().getOrDefault("distance",java.math.BigDecimal.ONE).doubleValue();if(direction.lengthSquared()==0||!Double.isFinite(distance)||distance<=0)yield false;boss.setVelocity(direction.normalize().multiply(distance));yield true;}if(id.equals("inspire")){double amount=definition.parameters().getOrDefault("base-temporary-health",java.math.BigDecimal.ZERO).add(new java.math.BigDecimal(modifier(custody.effectiveStats().get(Stat.CHR)))).max(java.math.BigDecimal.ZERO).doubleValue();if(amount<=boss.getAbsorptionAmount())yield false;boss.setAbsorptionAmount(amount);attempt.effects.put("absorption",now+java.time.Duration.ofMillis(durationTicks(definition)*50L).toNanos());yield true;}var type=id.equals("stone_skin")?PotionEffectType.RESISTANCE:id.equals("rallying_cry")?PotionEffectType.SPEED:PotionEffectType.STRENGTH;boss.addPotionEffect(new PotionEffect(type,durationTicks(definition),0,false,false),true);yield true;}
|
||||
case ATK->{var victims=abilityVictims(definition,boss,target,eligible,range);double amount=abilityAmount(definition,custody,false);if(amount<=0)yield false;for(int index=0;index<victims.size();index++){var victim=victims.get(index);double strike=id.equals("chain_lightning")?java.math.BigDecimal.valueOf(amount).multiply(definition.parameters().getOrDefault("jump-multiplier",java.math.BigDecimal.ONE).pow(index)).doubleValue():amount;victim.damage(strike,boss);if(id.equals("frost_shard")||id.equals("frost_nova"))victim.addPotionEffect(new PotionEffect(PotionEffectType.SLOWNESS,40,0,false,true),true);}yield !victims.isEmpty();}
|
||||
case BUF->{if(id.equals("quickstep")){var direction=target.getLocation().toVector().subtract(boss.getLocation().toVector());double distance=definition.parameters().getOrDefault("distance",java.math.BigDecimal.ONE).doubleValue();if(direction.lengthSquared()==0||!Double.isFinite(distance)||distance<=0)yield false;boss.setVelocity(direction.normalize().multiply(distance));yield true;}if(id.equals("inspire")){double amount=definition.parameters().getOrDefault("base-temporary-health",java.math.BigDecimal.ZERO).add(new java.math.BigDecimal(modifier(custody.effectiveStats().get(Stat.CHR)))).max(java.math.BigDecimal.ZERO).doubleValue();if(amount<=boss.getAbsorptionAmount())yield false;boss.setAbsorptionAmount(amount);attempt.effects.put("absorption",now+java.time.Duration.ofMillis(durationTicks(definition)*50L).toNanos());yield true;}var type=id.equals("stone_skin")?PotionEffectType.RESISTANCE:id.equals("rallying_cry")?PotionEffectType.SPEED:PotionEffectType.STRENGTH;characterPotion(boss,new PotionEffect(type,durationTicks(definition),0,false,false),true);yield true;}
|
||||
case ATK->{var victims=abilityVictims(definition,boss,target,eligible,range);double amount=abilityAmount(definition,custody,false);if(amount<=0)yield false;for(int index=0;index<victims.size();index++){var victim=victims.get(index);double strike=id.equals("chain_lightning")?java.math.BigDecimal.valueOf(amount).multiply(definition.parameters().getOrDefault("jump-multiplier",java.math.BigDecimal.ONE).pow(index)).doubleValue():amount;victim.damage(strike,boss);if(id.equals("frost_shard")||id.equals("frost_nova"))characterPotion(victim,new PotionEffect(PotionEffectType.SLOWNESS,40,0,false,true),true);}yield !victims.isEmpty();}
|
||||
};
|
||||
if(cast)attempt.cooldowns.put(id,now+definition.cooldown().toNanos());
|
||||
completeCharacterCast(observation,cast,definition.category());
|
||||
}finally{endCharacterCast(observation);}
|
||||
}
|
||||
}
|
||||
private static int durationTicks(AbilityDefinition definition){var seconds=definition.parameters().get("duration-seconds");long ticks=seconds==null?Math.max(20,definition.cooldown().toMillis()/50):seconds.movePointRight(1).longValue();return (int)Math.max(1,Math.min(Integer.MAX_VALUE,ticks));}
|
||||
private boolean healBoss(Mob boss,double requested){var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);if(maximum==null||!Double.isFinite(requested)||requested<=0)return false;double accepted=Math.min(requested,maximum.getValue()-boss.getHealth());if(accepted<=0)return false;var event=new EntityRegainHealthEvent(boss,accepted,EntityRegainHealthEvent.RegainReason.CUSTOM);plugin.getServer().getPluginManager().callEvent(event);if(event.isCancelled()||!Double.isFinite(event.getAmount())||event.getAmount()<=0)return false;double before=boss.getHealth();boss.setHealth(Math.min(maximum.getValue(),before+event.getAmount()));return boss.getHealth()>before;}
|
||||
private boolean healBoss(Mob boss,double requested){return healBoss(boss,requested,null);}
|
||||
private boolean healBoss(Mob boss,double requested,games.dmg.fantasystick.events.CharacterBossEventBridge.Facts passiveFacts){var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);if(maximum==null||!Double.isFinite(requested)||requested<=0)return false;double initial=boss.getHealth();double accepted=Math.min(requested,maximum.getValue()-initial);if(accepted<=0)return false;var event=new EntityRegainHealthEvent(boss,accepted,EntityRegainHealthEvent.RegainReason.CUSTOM);plugin.getServer().getPluginManager().callEvent(event);if(event.isCancelled()||!Double.isFinite(event.getAmount())||event.getAmount()<=0){observeCharacterHealing(boss,event,requested,initial,initial,passiveFacts);return false;}double before=boss.getHealth();boss.setHealth(Math.min(maximum.getValue(),before+event.getAmount()));double after=boss.getHealth();observeCharacterHealing(boss,event,requested,before,after,passiveFacts);return after>before;}
|
||||
private static List<Player> abilityVictims(AbilityDefinition definition,Mob boss,Player target,List<Player> eligible,double range){if(definition.id().equals("chain_lightning")){var result=new ArrayList<Player>();result.add(target);int maximum=1+definition.parameters().getOrDefault("additional-targets",java.math.BigDecimal.ZERO).intValue();double jump=definition.parameters().getOrDefault("jump-range",java.math.BigDecimal.valueOf(range)).doubleValue();Player current=target;while(result.size()<maximum){var origin=current;var next=eligible.stream().filter(player->!result.contains(player)&&origin.hasLineOfSight(player)&&origin.getLocation().distanceSquared(player.getLocation())<=jump*jump).min(Comparator.comparingDouble((Player player)->origin.getLocation().distanceSquared(player.getLocation())).thenComparing(player->player.getUniqueId().toString())).orElse(null);if(next==null)break;result.add(next);current=next;}return List.copyOf(result);}double radius=definition.parameters().getOrDefault("radius",java.math.BigDecimal.valueOf(range)).doubleValue();var center=definition.id().equals("meteor")?target:boss;return definition.targetPolicy()==AbilityDefinition.TargetPolicy.AREA?eligible.stream().filter(player->center.hasLineOfSight(player)&¢er.getLocation().distanceSquared(player.getLocation())<=radius*radius).toList():List.of(target);}
|
||||
private void applyMobEffects(LivingEntity mob,List<DungeonState.Effect> effects){for(var effect:effects)if(effect.target()!=DungeonState.EffectTarget.PLAYERS){var type=effectType(effect.effect());if(type!=null)mob.addPotionEffect(new PotionEffect(type,PotionEffect.INFINITE_DURATION,effect.level()-1,false,effect.particles()));}}
|
||||
private void applyPlayerEffects(UUID dungeon,DungeonState.Room room,List<Player> players,Set<String> active){for(var effect:room.effects())if(effect.target()!=DungeonState.EffectTarget.MOBS){var type=effectType(effect.effect());if(type==null)continue;for(var player:players){String key=player.getUniqueId()+":"+effect.id();active.add(key);var current=effect(player,type);int amplifier=effect.level()-1;if(current!=null&¤t.getAmplifier()>amplifier)continue;ownedEffects.computeIfAbsent(key,ignored->new OwnedEffect(dungeon,player.getUniqueId(),type,amplifier,current));player.addPotionEffect(new PotionEffect(type,30,amplifier,false,effect.particles()),true);}}}
|
||||
private final Set<String> observedEffectKeys=new HashSet<>();
|
||||
private void applyPlayerEffects(UUID dungeon,DungeonState.Room room,List<Player> players,Set<String> active){for(var effect:room.effects())if(effect.target()!=DungeonState.EffectTarget.MOBS){var type=effectType(effect.effect());if(type==null)continue;for(var player:players){String key=player.getUniqueId()+":"+effect.id();active.add(key);var current=effect(player,type);int amplifier=effect.level()-1;if(current!=null&¤t.getAmplifier()>amplifier)continue;var owned=ownedEffects.computeIfAbsent(key,ignored->new OwnedEffect(dungeon,player.getUniqueId(),type,amplifier,current,effectFacts(dungeon,room,player,effect)));player.addPotionEffect(new PotionEffect(type,30,amplifier,false,effect.particles()),true);if(ownerEvents!=null)try{var applied=effect(player,type);if(applied!=null&&applied.getAmplifier()==amplifier&&observedEffectKeys.add(key))observe("effect.applied",owned.observation(),Map.of("application_status","native_layer_confirmed","reason","eligible_room_effect"));}catch(RuntimeException unavailable){/* This optional confirmation cannot interrupt native application or room runtime. */}}}}
|
||||
private void cleanupOwnedEffects(Set<String> active){for(var entry:new ArrayList<>(ownedEffects.entrySet()))if(!active.contains(entry.getKey()))restoreOwned(entry.getKey(),entry.getValue());}
|
||||
private void cleanupOwnedDungeon(UUID dungeon){for(var entry:new ArrayList<>(ownedEffects.entrySet()))if(entry.getValue().dungeon().equals(dungeon))restoreOwned(entry.getKey(),entry.getValue());}
|
||||
private void restoreOwned(String key,OwnedEffect owned){ownedEffects.remove(key);var player=plugin.getServer().getPlayer(owned.player());if(player==null)return;var current=effect(player,owned.type());if(current!=null&¤t.getAmplifier()==owned.amplifier()){player.removePotionEffect(owned.type());if(owned.prior()!=null&&owned.prior().getDuration()>0)player.addPotionEffect(owned.prior(),true);}}
|
||||
private void restoreOwned(String key,OwnedEffect owned){ownedEffects.remove(key);boolean observed=observedEffectKeys.remove(key);var player=plugin.getServer().getPlayer(owned.player());if(player==null){if(observed)observe("effect.ended",owned.observation(),Map.of("reason","owned_room_layer_released","cleanup_status","player_unavailable"));return;}var current=effect(player,owned.type());if(current!=null&¤t.getAmplifier()==owned.amplifier()){player.removePotionEffect(owned.type());if(owned.prior()!=null&&owned.prior().getDuration()>0)player.addPotionEffect(owned.prior(),true);if(observed)observe("effect.ended",owned.observation(),Map.of("reason","owned_room_layer_released","cleanup_status","native_restore_returned","prior_layer_status",owned.prior()==null?"absent":"restored_request"));}else if(observed)observe("effect.ended",owned.observation(),Map.of("reason","owned_room_layer_released","cleanup_status","unrelated_current_layer_preserved"));}
|
||||
private static PotionEffect effect(LivingEntity entity,PotionEffectType type){return entity.getActivePotionEffects().stream().filter(value->value.getType().equals(type)).findFirst().orElse(null);}
|
||||
private static PotionEffectType effectType(String key){var value=NamespacedKey.fromString(key);return value==null?null:PotionEffectType.getByKey(value);}
|
||||
private void contain(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;for(var entity:safe(world.getEntities()))if(managed(entity)&&dungeon.id().equals(id(entity,dungeonKey))&&room.id().equals(id(entity,roomKey))&&!inside(entity.getLocation(),dungeon,room)){relocating.add(entity);try{entity.teleport(new Location(world,(room.bounds().minX()+room.bounds().maxX()+1)/2d,room.bounds().minY()+1,(room.bounds().minZ()+room.bounds().maxZ()+1)/2d));if(entity instanceof LivingEntity living)living.setVelocity(new org.bukkit.util.Vector());}finally{relocating.remove(entity);}}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(managedEnemy(event.getEntity())){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),id(event.getEntity(),dungeonKey),id(event.getEntity(),roomKey))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID dungeon=id(by.getDamager(),dungeonKey),room=id(by.getDamager(),roomKey);if(!(event.getEntity() instanceof Player player)||dungeon==null||room==null||eligible(player,dungeon,room)==null)event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void accepted(EntityDamageByEntityEvent event){if(!managedEnemy(event.getEntity())||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;var player=source(event);UUID dungeon=id(event.getEntity(),dungeonKey),room=id(event.getEntity(),roomKey);var quest=player==null||dungeon==null||room==null?null:eligible(player,dungeon,room);var attempt=room==null?null:attempts.get(new AttemptKey(dungeon,room));if(quest!=null&&attempt!=null){attempt.represented.add(quest.id());attempt.threat.merge(player.getUniqueId(),event.getFinalDamage(),Double::sum);attempt.lastCombat=System.nanoTime();attempt.cooldowns.remove("recovery");}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void bossAccepted(EntityDamageByEntityEvent event){if(!(event.getDamager() instanceof Mob boss)||!managedEnemy(boss)||!(event.getEntity() instanceof Player player)||event.getFinalDamage()<=0)return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null)return;if(custody.character().passives().contains("stick_knockback")&&passives.find("stick_knockback").isPresent()){double radians=Math.toRadians(boss.getLocation().getYaw()),x=Math.sin(radians),z=-Math.cos(radians);if(Double.isFinite(x)&&Double.isFinite(z))player.knockback(1,x,z);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void bossAccepted(EntityDamageByEntityEvent event){if(!(event.getDamager() instanceof Mob boss)||!managedEnemy(boss)||!(event.getEntity() instanceof Player player)||event.getFinalDamage()<=0)return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null)return;var observation=characterMeleeFacts(boss,player,event);observeCharacterMeleeDamage(observation,event);if(custody.character().passives().contains("stick_knockback")&&passives.find("stick_knockback").isPresent()){double radians=Math.toRadians(boss.getLocation().getYaw()),x=Math.sin(radians),z=-Math.cos(radians);if(Double.isFinite(x)&&Double.isFinite(z)){var passive=characterMeleePassiveFacts(observation,boss,player,x,z);org.bukkit.util.Vector before=null;try{before=player.getVelocity().clone();}catch(RuntimeException unavailable){/* Optional observation only. */}var previous=characterMeleeKnockback.get();var calls=new ArrayList<NativeKnockback>();characterMeleeKnockback.set(calls);try{player.knockback(1,x,z);}finally{if(previous==null)characterMeleeKnockback.remove();else characterMeleeKnockback.set(previous);}observeCharacterMeleeKnockback(passive,player,before,calls);}}}
|
||||
private games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterMeleeFacts(Mob boss,Player player,EntityDamageByEntityEvent event){
|
||||
try{var events=characterBossEvents;var original=characterBossShellFacts.get(boss.getUniqueId());if(events==null||original==null)return null;var data=new LinkedHashMap<String,Object>();data.put("source_location",nativeLocation(boss.getLocation()));data.putAll(characterTargetData(player));data.put("damage_source",event.getCause()==EntityDamageEvent.DamageCause.ENTITY_ATTACK?"native_melee":"native_entity_damage");data.put("damage_cause",event.getCause().name());var cast=characterCasts.get().peek();if(cast==null)characterCasts.remove();else if(cast.shell.equals(boss.getUniqueId()))data.put("cast_id",cast.facts.request().action().toString());return events.transition(original,data);}catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private void observeCharacterMeleeDamage(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,EntityDamageByEntityEvent event){
|
||||
try{if(facts==null||characterBossEvents==null||facts.data().containsKey("cast_id"))return;nextTick.accept(()->{try{double amount=event.getFinalDamage();var data=new LinkedHashMap<String,Object>();data.put("outcome",event.isCancelled()?"cancelled":Double.isFinite(amount)&&amount>0?"accepted":"no_op");if(Double.isFinite(amount))data.put("final_damage",amount);else data.put("final_damage_status","non_finite_unquantified");data.put("native_cancelled",event.isCancelled());data.put("causation","original_native_character_shell_damage_event");data.put("confirmation_quality","final_native_event_decision_not_health_setter_proof");characterBossEvents.damage(facts,data);}catch(RuntimeException unavailable){/* Native-thread confirmation only; never gameplay authority. */}});}catch(RuntimeException unavailable){/* Optional metadata never affects original native damage. */}
|
||||
}
|
||||
private games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterMeleePassiveFacts(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts melee,Mob boss,Player player,double x,double z){
|
||||
try{if(melee==null||characterBossEvents==null)return null;return characterBossEvents.transition(melee,Map.of("passive_id","stick_knockback","melee_damage_operation_id",melee.request().action().toString(),"knockback_strength",1d,"direction_x",x,"direction_z",z,"target_location",nativeLocation(player.getLocation()),"source_location",nativeLocation(boss.getLocation())));}catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private void observeCharacterMeleeKnockback(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,Player player,org.bukkit.util.Vector before,List<NativeKnockback> calls){
|
||||
try{if(facts==null||before==null||characterBossEvents==null||calls.size()!=1)return;var nativeEvent=calls.getFirst().event();var after=player.getVelocity();if(nativeEvent.getEntity()!=player||nativeEvent.isCancelled()||after==null||!Double.isFinite(after.getX())||!Double.isFinite(after.getY())||!Double.isFinite(after.getZ())||after.equals(before))return;var data=new LinkedHashMap<String,Object>();data.put("outcome","accepted");data.put("effect_type","native_knockback");data.put("application_status","native_event_and_velocity_confirmed");data.put("velocity_before",Map.of("x",before.getX(),"y",before.getY(),"z",before.getZ()));data.put("velocity_after",Map.of("x",after.getX(),"y",after.getY(),"z",after.getZ()));data.put("native_final_cancelled",false);data.put("causation","original_native_character_shell_damage_passive");characterBossEvents.effectApplied(facts,facts.request().action(),data);}catch(RuntimeException unavailable){/* Native effect/velocity remain authoritative. */}
|
||||
}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void bossKill(org.bukkit.event.entity.PlayerDeathEvent event){if(!(event.getEntity().getLastDamageCause() instanceof EntityDamageByEntityEvent damage)||!(damage.getDamager() instanceof Mob boss)||!managedEnemy(boss))return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null||!custody.character().passives().contains("health_steal")||passives.find("health_steal").isEmpty()||eligible(event.getPlayer(),id(boss,dungeonKey),id(boss,roomKey))==null)return;healBoss(boss,Math.min(Double.MAX_VALUE,healthStealHealing.get().doubleValue()));}
|
||||
public void accept(AbilityReport report){if(report.type()==AbilityReport.Type.DAMAGE)return;var active=sessions.activeCharacter(report.sourceCharacter()).filter(sessions::current).orElse(null);if(active==null)return;var player=plugin.getServer().getPlayer(active.player());if(player==null)return;double value=report.amount().doubleValue();if(!Double.isFinite(value)||value<=0)return;for(var entry:attempts.entrySet())if(eligible(player,entry.getKey().dungeon(),entry.getKey().room())!=null)entry.getValue().threat.merge(player.getUniqueId(),value*.5d,Double::sum);}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){attempt.boss=null;defeatingDungeons.add(dungeonId);cleanupDungeon(dungeonId);clearRoom(dungeon,room,attempt,true);return;}UUID slot=id(event.getEntity(),slotKey);attempt.entities.remove(event.getEntity().getUniqueId());if(slot==null)return;encounterOrigin(event.getEntity()).ifPresent(origin->pendingKills.putIfAbsent(origin.source(),new PendingKill(dungeonId,roomId,generation,slot)));}
|
||||
private void clearRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,boolean boss){var key=new AttemptKey(dungeon.id(),room.id());if(!completing.add(key))return;var quests=attempt.represented.stream().map(adventures::findQuest).flatMap(Optional::stream).toList();var awards=quests.stream().map(quest->awardQuest(dungeon,room,attempt.generation,quest,boss)).toArray(CompletableFuture[]::new);var completion=CompletableFuture.allOf(awards).thenCompose(ignored->dungeons.markCleared(dungeon.id(),attempt.generation,room.id())).thenCompose(run->boss?dungeons.defeat(dungeon.id(),attempt.generation):CompletableFuture.completedFuture(run)).thenCompose(run->boss?adventures.expireOfferQuests(dungeon.id()).thenApply(ignored->run):CompletableFuture.completedFuture(run)).whenComplete((run,failure)->schedule(()->{completing.remove(key);if(boss)defeatingDungeons.remove(dungeon.id());if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon room completion failed",failure);return;}cleanupAttempt(key);if(boss)cleanupDungeon(dungeon.id());}));inFlight.add(completion);completion.whenComplete((ignored,failure)->inFlight.remove(completion));}
|
||||
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,AdventureQuestState.Quest quest,boolean completeQuest){var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);if(!completeQuest)return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients)).thenApply(ignored->null);if(fishing==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence unavailable"));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);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);if(carrier==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy carrier unavailable"));UUID trophy=UUID.nameUUIDFromBytes(("dungeon:"+quest.id()).getBytes(StandardCharsets.UTF_8));var plan=new FishingState.Trophy(trophy,FishingState.TrophyType.DUNGEON,carrier,quest.catalog(),dungeon.id(),quest.id(),generation,"Trophy of "+dungeon.name(),dungeon.name(),rewards);return fishing.earnTrophy(plan).thenCompose(earned->{if(!earned)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->(Void)null).toCompletableFuture();}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){var origin=encounterOrigin(event.getEntity());if(origin.isEmpty())return;endNativeEffects(event.getEntity(),"accepted_native_death");observeCharacterShellEffectsEnded(event.getEntity(),"accepted_native_death");captureBossDefeat(attempt,event.getEntity(),room);if(room.boss().character()==null)attempt.deathObservation=nativeFacts(dungeon,room,attempt,event.getEntity(),null,true,nativeTrigger(event),"death_location");else if(attempt.characterDeathObservation==null){attempt.characterDeathObservation=characterShellTransition(attempt,event.getEntity(),"death_location",nativeTrigger(event));characterBossShellFacts.remove(event.getEntity().getUniqueId());}pendingBosses.putIfAbsent(origin.orElseThrow().source(),event.getEntity().getUniqueId());attempt.boss=null;defeatingDungeons.add(dungeonId);cleanupDungeon(dungeonId);clearRoom(dungeon,room,attempt,true);return;}UUID slot=id(event.getEntity(),slotKey);var origin=encounterOrigin(event.getEntity());if(slot==null||origin.isEmpty())return;endNativeEffects(event.getEntity(),"accepted_native_death");attempt.entities.remove(event.getEntity().getUniqueId());pendingKills.putIfAbsent(origin.orElseThrow().source(),new PendingKill(event.getEntity().getUniqueId(),dungeonId,roomId,generation,slot,nativeFacts(dungeon,room,attempt,event.getEntity(),slot,false,nativeTrigger(event),"death_location")));}
|
||||
private void captureBossDefeat(Attempt attempt,LivingEntity entity,DungeonState.Room room){
|
||||
if(attempt.defeatedBoss!=null)return;
|
||||
attempt.defeatedBoss=entity.getUniqueId();var location=entity.getLocation();
|
||||
var audit=new LinkedHashMap<String,String>();audit.put("boss_id",attempt.defeatedBoss.toString());
|
||||
audit.put("room_id",room.id().toString());
|
||||
audit.put("defeated_at",java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString());
|
||||
if(location!=null&&location.getWorld()!=null){var world=location.getWorld();
|
||||
audit.put("defeat_world_id",world.getUID().toString());
|
||||
audit.put("defeat_world_name",Objects.toString(world.getName(),"unavailable"));
|
||||
audit.put("defeat_x",Double.toString(location.getX()));
|
||||
audit.put("defeat_y",Double.toString(location.getY()));
|
||||
audit.put("defeat_z",Double.toString(location.getZ()));
|
||||
}
|
||||
attempt.defeatAudit=Map.copyOf(audit);
|
||||
}
|
||||
private void clearRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,boolean boss){
|
||||
var key=new AttemptKey(dungeon.id(),room.id());if(!completing.add(key))return;
|
||||
var quests=attempt.represented.stream().map(adventures::findQuest).flatMap(Optional::stream).toList();
|
||||
var awards=quests.stream().map(quest->awardQuest(dungeon,room,attempt,quest,boss))
|
||||
.toArray(CompletableFuture[]::new);
|
||||
var completion=CompletableFuture.allOf(awards)
|
||||
.thenCompose(ignored->dungeons.markCleared(dungeon.id(),attempt.generation,room.id()))
|
||||
.thenCompose(run->boss?dungeons.defeat(dungeon.id(),attempt.generation,
|
||||
completionAudit(dungeon,attempt,quests)):CompletableFuture.completedFuture(run))
|
||||
.thenCompose(run->boss?adventures.expireOfferQuests(dungeon.id()).thenApply(ignored->run):CompletableFuture.completedFuture(run))
|
||||
.whenComplete((run,failure)->schedule(()->{
|
||||
completing.remove(key);if(boss)defeatingDungeons.remove(dungeon.id());
|
||||
if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon room completion failed",failure);return;}
|
||||
observe("dungeon.room.cleared",attempt.observation,Map.of("reason",boss?"boss_room_progress_committed":"ordinary_or_empty_room_progress_committed","room_cleared",run.clearedRooms().contains(room.id()),"represented_quest_ids",quests.stream().map(value->value.id().toString()).sorted().toList(),"represented_units",quests.stream().sorted(Comparator.comparing(AdventureQuestState.Quest::id)).map(DungeonController::questData).toList(),"reward_status","no_room_reward"));
|
||||
cleanupAttempt(key);
|
||||
if(boss){observeCharacterBossDefeat(attempt.characterDeathObservation);observe("dungeon.entity.defeated",attempt.deathObservation,Map.of("reason","native_boss_completion_committed","settlement_status","acknowledged_boss_completion","defeated_slot",false));observe("dungeon.run.closed",attempt.deathObservation==null?attempt.observation:attempt.deathObservation,Map.of("reason","boss_completion_global_close","run_defeated",run.defeated(),"reward_owner","US-085","completion_status","acknowledged_global_close"));cleanupDungeon(dungeon.id());
|
||||
if(auditEvents!=null)try{auditEvents.completed(run);}catch(RuntimeException error){
|
||||
plugin.getLogger().log(java.util.logging.Level.WARNING,
|
||||
"Committed dungeon completion audit publication failed",error);
|
||||
}
|
||||
}
|
||||
}));
|
||||
inFlight.add(completion);completion.whenComplete((ignored,failure)->inFlight.remove(completion));
|
||||
}
|
||||
private Map<String,String> completionAudit(DungeonState.Dungeon dungeon,Attempt attempt,
|
||||
List<AdventureQuestState.Quest> quests){
|
||||
if(attempt.defeatedBoss==null||!attempt.defeatAudit.containsKey("defeated_at"))
|
||||
throw new IllegalStateException("Native dungeon boss proof unavailable");
|
||||
var audit=new LinkedHashMap<>(attempt.defeatAudit);
|
||||
audit.put("dungeon_name",dungeon.name());
|
||||
var represented=quests.stream().filter(quest->fishing.earnedTrophy(UUID.nameUUIDFromBytes(
|
||||
("dungeon:"+quest.id()).getBytes(StandardCharsets.UTF_8))))
|
||||
.sorted(Comparator.comparing(AdventureQuestState.Quest::id)).toList();
|
||||
audit.put("represented_count",Integer.toString(represented.size()));
|
||||
for(int index=0;index<represented.size();index++){
|
||||
var quest=represented.get(index);
|
||||
var prefix="represented_"+index+"_";
|
||||
audit.put(prefix+"quest_id",quest.id().toString());
|
||||
audit.put(prefix+"unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));
|
||||
audit.put(prefix+"unit_id",quest.unit().toString());
|
||||
audit.put(prefix+"accepted_roster",quest.snapshot().stream().sorted()
|
||||
.map(UUID::toString).collect(java.util.stream.Collectors.joining(",")));
|
||||
audit.put(prefix+"receipt_id",UUID.nameUUIDFromBytes(("dungeon:"+quest.id())
|
||||
.getBytes(StandardCharsets.UTF_8)).toString());
|
||||
}
|
||||
return audit;
|
||||
}
|
||||
public void replayCompletions(){
|
||||
var events=auditEvents;if(events==null)return;
|
||||
dungeons.pendingCompletions(run->{try{return games.dmg.fantasystick.events.DungeonEventBridge.exported(
|
||||
plugin.getDataFolder().toPath(),
|
||||
games.dmg.fantasystick.events.DungeonEventBridge.completionEventId(run),
|
||||
run.dungeon(),run.generation(),UUID.fromString(run.completionAudit().get("boss_id")));}
|
||||
catch(java.io.IOException error){throw new java.io.UncheckedIOException(error);}})
|
||||
.whenComplete((pending,failure)->{
|
||||
if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,
|
||||
"Committed dungeon completion export reconciliation failed",failure);return;}
|
||||
for(var run:pending)try{events.completed(run);}catch(RuntimeException error){
|
||||
plugin.getLogger().log(java.util.logging.Level.WARNING,
|
||||
"Committed dungeon completion audit replay failed",error);
|
||||
}
|
||||
});
|
||||
}
|
||||
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,AdventureQuestState.Quest quest,boolean completeQuest){UUID generation=attempt.generation;var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);if(!completeQuest)return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients)).thenApply(ignored->null);if(fishing==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence unavailable"));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);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);if(carrier==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy carrier unavailable"));UUID trophy=UUID.nameUUIDFromBytes(("dungeon:"+quest.id()).getBytes(StandardCharsets.UTF_8));var audit=new LinkedHashMap<String,String>(attempt.defeatAudit);audit.put("unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));audit.put("unit_id",quest.unit().toString());audit.put("accepted_roster",quest.snapshot().stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(",")));var plan=new FishingState.Trophy(trophy,FishingState.TrophyType.DUNGEON,carrier,quest.catalog(),dungeon.id(),quest.id(),generation,"Trophy of "+dungeon.name(),dungeon.name(),rewards,FishingState.TrophyPhase.PREPARED,audit);return fishing.earnTrophy(plan).thenCompose(earned->{if(!earned)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->(Void)null).toCompletableFuture();}
|
||||
private List<UUID> recipients(DungeonState.Dungeon dungeon,DungeonState.Room room,AdventureQuestState.Quest quest,DungeonState.Run run){var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(run.characterClaims().contains(new DungeonState.CharacterClaim(room.id(),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(),dungeon,room))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();}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void target(EntityTargetLivingEntityEvent event){if(managedEnemy(event.getEntity())&&event.getTarget()!=null){UUID dungeon=id(event.getEntity(),dungeonKey);UUID room=id(event.getEntity(),roomKey);var attempt=dungeon==null||room==null?null:attempts.get(new AttemptKey(dungeon,room));if(!(event.getTarget() instanceof Player player)||dungeon==null||room==null||eligible(player,dungeon,room)==null||attempt!=null&&attempt.target!=null&&!attempt.target.equals(player.getUniqueId()))event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void projectile(ProjectileLaunchEvent event){if(event.getEntity().getShooter() instanceof Entity shooter&&managedEnemy(shooter)){var data=event.getEntity().getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,PROJECTILE);copy(data,dungeonKey,shooter);copy(data,roomKey,shooter);copy(data,generationKey,shooter);}}
|
||||
@@ -140,7 +279,69 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(EntityTeleportEvent event){if(managed(event.getEntity())&&!relocating.contains(event.getEntity()))event.setCancelled(true);}
|
||||
private boolean managedEnemy(Entity entity){return entity!=null&&ENEMY.equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
|
||||
private Player source(EntityDamageByEntityEvent event){if(event.getDamager() instanceof Player player)return player;if(event.getDamager() instanceof Projectile projectile&&projectile.getShooter() instanceof Player player)return player;return null;}
|
||||
private void cleanupAttempt(AttemptKey key){var attempt=attempts.remove(key);if(attempt==null)return;for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity)&&key.dungeon().equals(id(entity,dungeonKey))&&key.room().equals(id(entity,roomKey)))entity.remove();attempt.entities.clear();attempt.boss=null;}
|
||||
private games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts facts(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,Player trigger,Map<String,Object> details){var events=ownerEvents;if(events==null)return null;try{return events.runtime(UUID.randomUUID(),trigger==null?null:events.actor(trigger),dungeon,room,generation,details);}catch(RuntimeException unavailable){return null;}}
|
||||
private void observe(String type,games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts facts,Map<String,Object> details){var events=ownerEvents;if(events==null||facts==null)return;try{events.observed(type,facts,details);}catch(RuntimeException failure){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon observation unavailable",failure);}}
|
||||
private static Map<String,Object> questData(AdventureQuestState.Quest quest){if(quest==null)return Map.of("trigger_quest_status","unavailable");return Map.of("quest_id",quest.id().toString(),"unit_id",quest.unit().toString(),"unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT),"accepted_mode",quest.participationMode().value(),"accepted_roster",quest.snapshot().stream().sorted().map(UUID::toString).toList());}
|
||||
private static Player nativeTrigger(EntityDeathEvent event){try{return event.getDamageSource()!=null&&event.getDamageSource().getCausingEntity() instanceof Player player?player:null;}catch(RuntimeException unavailable){return null;}}
|
||||
private static Map<String,Object> nativeLocation(Location at){if(at==null||at.getWorld()==null)return Map.of("location_status","unavailable");return Map.of("world_id",at.getWorld().getUID().toString(),"world_name",Objects.toString(at.getWorld().getName(),"unavailable"),"x",at.getX(),"y",at.getY(),"z",at.getZ(),"yaw",(double)at.getYaw(),"pitch",(double)at.getPitch(),"captured_at",java.time.Instant.now().toString(),"quality","native_original");}
|
||||
private games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts nativeFacts(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,Entity entity,UUID slot,boolean boss,Player trigger,String locationField){try{if(ownerEvents==null)return null;var data=new LinkedHashMap<String,Object>();data.put("native_entity_id",entity.getUniqueId().toString());data.put("entity_type",entity.getType().name());data.put("entity_role",boss?"native_boss":"ordinary_slot");data.put("entity_name",boss?room.boss().name():room.mobs().stream().filter(value->value.id().equals(slot)).map(DungeonState.Spawn::name).findFirst().orElse("unavailable"));if(slot!=null)data.put("spawn_slot_id",slot.toString());data.put(locationField,nativeLocation(entity.getLocation()));data.put("managed_source_id",UUID.nameUUIDFromBytes((dungeon.id()+":"+room.id()+":"+attempt.generation+":"+(slot==null?"boss":slot.toString())).getBytes(StandardCharsets.UTF_8)).toString());data.put("damage_action_capture_quality",locationField.equals("death_location")?"unavailable_not_in_native_death":"not_applicable_native_observation");if(attempt.observation!=null)data.put("activation_operation_id",attempt.observation.operation().toString());if(trigger==null&&!locationField.equals("death_location")&&attempt.observation!=null){var original=new LinkedHashMap<>(attempt.observation.data());original.putAll(data);var actor=attempt.observation.actor();if(actor!=null&&actor.context()!=null)original.put("triggering_player_id",actor.context().playerId().toString());return new games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts(UUID.randomUUID(),actor,original);}data.putAll(trigger==null?Map.of("trigger_quest_status","unavailable"):questData(eligible(trigger,dungeon.id())));if(trigger!=null)data.put("triggering_player_id",trigger.getUniqueId().toString());return facts(dungeon,room,attempt.generation,trigger,data);}catch(RuntimeException unavailable){return null;}}
|
||||
private void observeSpawn(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,Entity entity,UUID slot,boolean boss){try{if(ownerEvents==null||entity==null||!entity.isValid()||!managedEnemy(entity)||!attempt.generation.equals(id(entity,generationKey))||!loaded(entity.getWorld(),new DungeonState.Position(entity.getLocation().getX(),entity.getLocation().getY(),entity.getLocation().getZ(),entity.getLocation().getYaw(),entity.getLocation().getPitch())))return;var observation=nativeFacts(dungeon,room,attempt,entity,slot,boss,null,"spawn_location");observe("dungeon.entity.spawned",observation,Map.of("materialization_status","loaded_valid_registered_entity","attempt_capture_quality","native_transient_not_persisted"));observeNativeEffects(room,entity,observation);}catch(RuntimeException unavailable){/* Observation cannot undo returned native calls. */}}
|
||||
private void observeCharacterBossSpawn(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,Entity entity){try{var events=characterBossEvents;if(events==null||entity==null||!entity.isValid()||!managedEnemy(entity)||!attempt.generation.equals(id(entity,generationKey)))return;var at=entity.getLocation();if(at==null||!loaded(entity.getWorld(),new DungeonState.Position(at.getX(),at.getY(),at.getZ(),at.getYaw(),at.getPitch())))return;var facts=events.runtime(dungeon,room,attempt.generation,entity.getUniqueId(),entity.getType().name(),nativeLocation(at));characterBossShellFacts.put(entity.getUniqueId(),facts);events.spawned(facts);if(entity instanceof LivingEntity living)observeCharacterPassives(room,living,facts,at);}catch(RuntimeException unavailable){/* Optional confirmation cannot undo or interrupt character-boss native materialization. */}}
|
||||
private void observeCharacterPassives(DungeonState.Room room,LivingEntity shell,games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,Location at){
|
||||
try{var audit=characterBossEffects;if(audit==null)return;for(var id:room.boss().character().character().passives()){
|
||||
PotionEffectType type=switch(id){case "speed_i"->PotionEffectType.SPEED;case "low_light_vision"->PotionEffectType.NIGHT_VISION;default->null;};
|
||||
try{if(type!=null&&passives.find(id).isPresent())audit.passive(facts,shell,id,new PotionEffect(type,PotionEffect.INFINITE_DURATION,0,false,false),nativeLocation(at));}catch(RuntimeException unavailable){/* One optional layer cannot hide another independent equipped passive. */}
|
||||
}}catch(RuntimeException unavailable){/* Native spawn and passive state remain authoritative. */}
|
||||
}
|
||||
private games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterShellTransition(Attempt attempt,Entity entity,String locationField,Player trigger){
|
||||
try{var events=characterBossEvents;var original=characterBossShellFacts.get(entity.getUniqueId());if(events==null||original==null||!entity.isValid()||!managedEnemy(entity)||!entity.getUniqueId().equals(attempt.boss)||!attempt.generation.equals(id(entity,generationKey)))return null;var at=entity.getLocation();var data=new LinkedHashMap<String,Object>();data.put(locationField,nativeLocation(at));data.put("damage_action_capture_quality",locationField.equals("death_location")?"unavailable_not_in_native_death":"not_applicable_native_observation");if(trigger!=null){data.put("triggering_player_id",trigger.getUniqueId().toString());data.put("triggering_player_location",nativeLocation(trigger.getLocation()));data.putAll(questData(eligible(trigger,attempt.dungeon)));}return events.transition(original,data);}catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private Map<String,Object> characterTargetData(Player target){var data=new LinkedHashMap<String,Object>();data.put("target_kind","accepted_fantasy_player");data.put("target_player_id",target.getUniqueId().toString());data.put("target_location",nativeLocation(target.getLocation()));sessions.active(target.getUniqueId()).ifPresent(active->data.put("target_character_id",active.character().toString()));return data;}
|
||||
private CharacterCast beginCharacterCast(Mob boss,Player target,AbilityDefinition definition,int slot){
|
||||
try{var events=characterBossEvents;var original=characterBossShellFacts.get(boss.getUniqueId());if(events==null||original==null)return null;var data=new LinkedHashMap<String,Object>();data.put("ability_id",definition.id());data.put("ability_category",definition.category().name());data.put("ability_slot_index",Integer.toString(slot));data.put("cooldown_millis",Long.toString(definition.cooldown().toMillis()));data.put("cooldown_status","ready_at_original_native_attempt");data.put("source_location",nativeLocation(boss.getLocation()));if(definition.category()==AbilityDefinition.Category.HEAL||definition.category()==AbilityDefinition.Category.BUF||definition.id().equals("purify")){data.put("target_kind","native_character_shell");data.put("target_entity_id",boss.getUniqueId().toString());data.put("target_location",nativeLocation(boss.getLocation()));}else data.putAll(characterTargetData(target));var facts=events.cast(original,data);var observation=new CharacterCast(facts,boss.getUniqueId());events.casting(facts);characterCasts.get().push(observation);return observation;}catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void characterAbilityDamage(EntityDamageByEntityEvent event){
|
||||
try{var stack=characterCasts.get();var cast=stack.peek();if(cast==null){characterCasts.remove();return;}if(characterBossEvents==null||!cast.shell.equals(event.getDamager().getUniqueId())||!(event.getEntity() instanceof Player player))return;cast.damage.add(new ObservedCharacterDamage(characterBossEvents.transition(cast.facts,characterTargetData(player)),event));}catch(RuntimeException unavailable){/* Retain no gameplay authority or asynchronous native event reference. */}
|
||||
}
|
||||
private boolean characterPotion(LivingEntity target,PotionEffect requested,boolean force){
|
||||
CharacterPotion call=null;
|
||||
try{var cast=characterCasts.get().peek();if(cast==null)characterCasts.remove();if(cast!=null&&characterBossEffects!=null&&characterBossEvents!=null){var data=new LinkedHashMap<String,Object>();data.put("target_entity_id",target.getUniqueId().toString());data.put("target_location",nativeLocation(target.getLocation()));if(target instanceof Player player)data.putAll(characterTargetData(player));else data.put("target_kind","native_character_shell");data.put("native_call_capture_quality","exact_synchronous_target_and_requested_effect");call=new CharacterPotion(cast,characterBossEvents.transition(cast.facts,data),target,requested,CharacterBossEffectAudit.layer(target,requested.getType()),new ArrayList<>());}}catch(RuntimeException unavailable){/* New metadata cannot suppress the original add call. */}
|
||||
if(call!=null)characterPotions.get().push(call);
|
||||
try{boolean returned=target.addPotionEffect(requested,force);try{if(call!=null)call.cast().effects.add(new CharacterBossEffectAudit.NativeCall(call.facts(),target,requested,call.prior(),returned,call.nativeEvents()));}catch(RuntimeException unavailable){/* Audit never changes the returned native decision. */}return returned;}
|
||||
finally{if(call!=null){var stack=characterPotions.get();stack.remove(call);if(stack.isEmpty())characterPotions.remove();}}
|
||||
}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void characterAbilityPotion(EntityPotionEffectEvent event){
|
||||
try{var audit=characterBossEffects;if(audit==null)return;if(event.getAction()==EntityPotionEffectEvent.Action.REMOVED||event.getAction()==EntityPotionEffectEvent.Action.CHANGED)audit.removal(event);
|
||||
var call=characterPotions.get().peek();if(call==null){characterPotions.remove();return;}
|
||||
if(event.getCause()!=EntityPotionEffectEvent.Cause.PLUGIN||event.getEntity()!=call.target()||!call.requested().equals(event.getNewEffect())
|
||||
||event.getSource()!=null&&!call.cast().shell.equals(event.getSource().getUniqueId()))return;
|
||||
call.nativeEvents().add(event);
|
||||
}catch(RuntimeException unavailable){/* Native potion decisions remain authoritative. */}
|
||||
}
|
||||
private games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterPassiveHealingFacts(Mob boss,DungeonState.CharacterBoss custody,String source,java.math.BigInteger base,java.math.BigInteger bonus){
|
||||
try{var events=characterBossEvents;var original=characterBossShellFacts.get(boss.getUniqueId());if(events==null||original==null)return null;var at=nativeLocation(boss.getLocation());var data=new LinkedHashMap<String,Object>();data.put("healing_source",source);data.put("recovery_base_amount",base.toString());data.put("recovery_bonus_amount",bonus.toString());var ids=new ArrayList<String>();if(custody.character().passives().contains("quicker_regeneration_i")){ids.add("quicker_regeneration_i");if(custody.character().passives().contains("quicker_regeneration_ii"))ids.add("quicker_regeneration_ii");}data.put("recovery_bonus_passive_ids",List.copyOf(ids));data.put("bonus_component_amounts_status","unavailable_from_combined_native_bonus_port");data.put("source_location",at);data.put("target_location",at);data.put("target_entity_id",boss.getUniqueId().toString());data.put("target_kind","native_character_shell");if(custody.character().passives().contains("quicker_regeneration_i")&&passives.find("quicker_regeneration_i").isPresent())data.put("passive_id","quicker_regeneration_i");return events.transition(original,data);}catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private void observeCharacterHealing(Mob boss,EntityRegainHealthEvent event,double requested,double before,double after,games.dmg.fantasystick.events.CharacterBossEventBridge.Facts passiveFacts){
|
||||
try{var cast=passiveFacts==null?characterCasts.get().peek():null;if(passiveFacts==null&&cast==null){characterCasts.remove();return;}if(characterBossEvents==null||cast!=null&&!cast.shell.equals(boss.getUniqueId()))return;var facts=passiveFacts==null?cast.facts:passiveFacts;double applied=Math.max(0,after-before);var outcome=event.isCancelled()?"cancelled":applied>0?"accepted":"no_op";if(cast!=null)cast.nativeOutcome=outcome;var data=new LinkedHashMap<String,Object>();data.put("outcome",outcome);data.put("requested_amount",requested);if(Double.isFinite(event.getAmount()))data.put("native_event_amount",event.getAmount());else data.put("native_event_amount_status","non_finite_refused");data.put("applied_amount",applied);data.put("health_before",before);data.put("health_after",after);data.put("native_cancelled",event.isCancelled());data.put("causation","original_character_boss_native_regain_event");characterBossEvents.healed(characterBossEvents.transition(facts,Map.of("target_entity_id",boss.getUniqueId().toString(),"target_kind","native_character_shell")),data);}catch(RuntimeException unavailable){/* Actual native event/health setter remains authority. */}
|
||||
}
|
||||
private void completeCharacterCast(CharacterCast cast,boolean cooldown,AbilityDefinition.Category category){
|
||||
try{if(cast==null||characterBossEvents==null)return;String outcome=cast.nativeOutcome;for(var observation:cast.damage){var event=observation.event();double amount=event.getFinalDamage();String result=event.isCancelled()?"cancelled":Double.isFinite(amount)&&amount>0?"accepted":"no_op";var data=new LinkedHashMap<String,Object>();data.put("outcome",result);if(Double.isFinite(amount))data.put("final_damage",amount);else data.put("final_damage_status","non_finite_refused");if(Double.isFinite(event.getDamage()))data.put("native_requested_damage",event.getDamage());data.put("native_cancelled",event.isCancelled());data.put("damage_cause",event.getCause().name());data.put("causation","original_character_boss_native_damage_event");characterBossEvents.damage(observation.facts(),data);if(outcome==null||result.equals("accepted"))outcome=result;}String effectOutcome=characterBossEffects==null?null:characterBossEffects.resolve(cast.effects);if(effectOutcome!=null){var details=new LinkedHashMap<String,Object>();details.put("native_effect_outcome",effectOutcome);var original=new LinkedHashMap<>(cast.facts.data());original.putAll(details);var resolved=new games.dmg.fantasystick.events.CharacterBossEventBridge.Facts(cast.facts.request(),cast.facts.custody(),original);if(outcome==null)outcome=!cooldown?"no_op":category==AbilityDefinition.Category.ATK?"native_damage_unobserved":"native_call_returned";characterBossEvents.resolved(resolved,cooldown,outcome);}else{if(outcome==null)outcome=!cooldown?"no_op":category==AbilityDefinition.Category.ATK?"native_damage_unobserved":"native_call_returned";characterBossEvents.resolved(cast.facts,cooldown,outcome);}}catch(RuntimeException unavailable){/* Observation cannot commit/refund/change a cooldown. */}
|
||||
}
|
||||
private void endCharacterCast(CharacterCast cast){if(cast==null)return;var stack=characterCasts.get();stack.remove(cast);if(stack.isEmpty())characterCasts.remove();}
|
||||
private games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterTargetFacts(Mob boss,Player target,LivingEntity previous,Attempt attempt){
|
||||
try{var events=characterBossEvents;var original=characterBossShellFacts.get(boss.getUniqueId());if(events==null||original==null)return null;var data=new LinkedHashMap<String,Object>();data.put("source_location",nativeLocation(boss.getLocation()));data.put("target_player_id",target.getUniqueId().toString());data.put("target_location",nativeLocation(target.getLocation()));data.put("target_kind","accepted_fantasy_player");data.put("accepted_threat",attempt.threat.getOrDefault(target.getUniqueId(),0d));data.put("reason",previous==null?"highest_accepted_threat":"fixation_invalidated_retarget");sessions.active(target.getUniqueId()).ifPresent(active->data.put("target_character_id",active.character().toString()));if(previous instanceof Player player)data.put("previous_target_player_id",player.getUniqueId().toString());data.putAll(questData(eligible(target,attempt.dungeon)));return events.transition(original,data);}catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private void observeCharacterTarget(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,Mob boss,Player target){try{if(characterBossEvents!=null&&facts!=null&&boss.getTarget()==target)characterBossEvents.targetChanged(facts);}catch(RuntimeException unavailable){/* Optional confirmation cannot interrupt native targeting or subsequent ability execution. */}}
|
||||
private void observeCharacterBossDefeat(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts){try{if(characterBossEvents!=null&&facts!=null)characterBossEvents.defeated(facts);}catch(RuntimeException unavailable){/* Optional publication cannot affect acknowledged native settlement. */}}
|
||||
private void observeCharacterShellEffectsEnded(Entity entity,String reason){var audit=characterBossEffects;if(audit==null)return;boolean confirmed;try{confirmed="accepted_native_death".equals(reason)||!entity.isValid();}catch(RuntimeException unavailable){try{audit.shellEndUnconfirmed(entity,reason,"remove_returned_validity_unavailable");}catch(RuntimeException optional){/* No native layer mutation or cleanup authority. */}return;}try{if(confirmed)audit.shellEnded(entity,reason);else audit.shellEndUnconfirmed(entity,reason,"remove_returned_still_valid");}catch(RuntimeException unavailable){/* No native layer mutation or cleanup authority. */}}
|
||||
private void observeCharacterBossRemoval(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,Entity entity,String reason){try{if(characterBossEvents==null||facts==null)return;String status;try{status=entity.isValid()?"remove_returned_still_valid":"native_no_longer_valid";}catch(RuntimeException unavailable){status="remove_returned_validity_unavailable";}characterBossEvents.removed(facts,reason,status);}catch(RuntimeException unavailable){/* Optional publication cannot affect native cleanup. */}}
|
||||
private final Map<UUID,List<games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts>> observedNativeEffects=new HashMap<>();
|
||||
private void observeNativeEffects(DungeonState.Room room,Entity entity,games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts spawn){try{if(spawn==null||!(entity instanceof LivingEntity living)||observedNativeEffects.containsKey(entity.getUniqueId()))return;var layers=new ArrayList<games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts>();for(var definition:room.effects()){if(definition.target()==DungeonState.EffectTarget.PLAYERS)continue;var type=effectType(definition.effect());var current=type==null?null:effect(living,type);if(current==null||current.getAmplifier()!=definition.level()-1)continue;var data=new LinkedHashMap<>(spawn.data());data.put("effect_id",definition.id().toString());data.put("effect",definition.effect());data.put("level",Integer.toString(definition.level()));data.put("particles",definition.particles());data.put("target_kind","managed_mob");data.put("action_location",data.get("spawn_location"));var operation=UUID.nameUUIDFromBytes((spawn.operation()+":"+definition.id()).getBytes(StandardCharsets.UTF_8));var layer=new games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts(operation,spawn.actor(),data);layers.add(layer);observe("effect.applied",layer,Map.of("application_status","loaded_valid_native_layer_confirmed","reason","configured_room_mob_effect"));}if(!layers.isEmpty())observedNativeEffects.put(entity.getUniqueId(),List.copyOf(layers));}catch(RuntimeException unavailable){/* Native application and ownership never depend on observation. */}}
|
||||
private void endNativeEffects(Entity entity,String reason){var layers=observedNativeEffects.remove(entity.getUniqueId());if(layers!=null)for(var layer:layers)observe("effect.ended",layer,Map.of("reason",reason,"cleanup_status","native_entity_lifecycle_ended","reward_status","no_effect_reward"));}
|
||||
private games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts effectFacts(UUID dungeon,DungeonState.Room room,Player player,DungeonState.Effect effect){try{if(ownerEvents==null)return null;var definition=dungeons.find(dungeon).orElse(null);var quest=eligible(player,dungeon);if(definition==null||quest==null)return null;var data=new LinkedHashMap<String,Object>(questData(quest));data.put("effect_id",effect.id().toString());data.put("effect",effect.effect());data.put("target_kind","player");data.put("target_player_id",player.getUniqueId().toString());data.put("level",Integer.toString(effect.level()));data.put("particles",effect.particles());data.put("action_location",nativeLocation(player.getLocation()));return facts(definition,room,quest.catalog(),player,data);}catch(RuntimeException unavailable){return null;}}
|
||||
private void cleanupAttempt(AttemptKey key){cleanupAttempt(key,"runtime_room_cleanup");}
|
||||
private void cleanupAttempt(AttemptKey key,String reason){var attempt=attempts.remove(key);if(attempt==null)return;for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity)&&key.dungeon().equals(id(entity,dungeonKey))&&key.room().equals(id(entity,roomKey))){var observation=removalFacts(key,attempt,entity);var characterObservation=characterShellTransition(attempt,entity,"removal_location",null);entity.remove();observeCharacterBossRemoval(characterObservation,entity,reason);observeCharacterShellEffectsEnded(entity,reason);characterBossShellFacts.remove(entity.getUniqueId());endNativeEffects(entity,reason);if(observation!=null){String status;try{status=entity.isValid()?"remove_returned_still_valid":"native_no_longer_valid";}catch(RuntimeException unavailable){status="remove_returned_validity_unavailable";}observe("dungeon.entity.removed",observation,Map.of("reason",reason,"reward_status","non_reward_cleanup","cleanup_status",status));}}attempt.entities.clear();attempt.boss=null;}
|
||||
private games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts removalFacts(AttemptKey key,Attempt attempt,Entity entity){try{if(ownerEvents==null||!entity.isValid()||!managedEnemy(entity)||!attempt.generation.equals(id(entity,generationKey)))return null;var dungeon=dungeons.find(key.dungeon()).orElse(null);var room=dungeons.room(key.dungeon(),key.room()).orElse(null);if(dungeon==null||room==null)return null;boolean boss=entity.getUniqueId().equals(attempt.boss);UUID slot=attempt.entities.get(entity.getUniqueId());if(!boss&&slot==null||boss&&(room.boss()==null||room.boss().character()!=null))return null;return nativeFacts(dungeon,room,attempt,entity,slot,boss,null,"removal_location");}catch(RuntimeException unavailable){return null;}}
|
||||
private void cleanupDungeon(UUID dungeon){cleanupOwnedDungeon(dungeon);for(var key:new ArrayList<>(attempts.keySet()))if(key.dungeon().equals(dungeon))cleanupAttempt(key);for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity)&&dungeon.equals(id(entity,dungeonKey)))entity.remove();}
|
||||
private void cleanupRuntime(){for(var key:new ArrayList<>(attempts.keySet()))cleanupAttempt(key);cleanupOwnedEffects(Set.of());completing.clear();defeatingDungeons.clear();startingRuns.clear();}
|
||||
private void schedule(Runnable task){if(closed)return;try{nextTick.accept(()->{if(!closed)task.run();});}catch(RuntimeException ignored){}}
|
||||
@@ -154,7 +355,7 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
private static void line(List<double[]> out,double from,double to,double fixedA,double fixedB,int axis){for(double value=from;value<to;value+=2)out.add(axis==0?new double[]{value,fixedA,fixedB}:axis==1?new double[]{fixedB,fixedA,value}:new double[]{fixedA,value,fixedB});out.add(axis==0?new double[]{to,fixedA,fixedB}:axis==1?new double[]{fixedB,fixedA,to}:new double[]{fixedA,to,fixedB});}
|
||||
private static Color randomColor(){var random=java.util.concurrent.ThreadLocalRandom.current();return Color.fromRGB(64+random.nextInt(192),64+random.nextInt(192),64+random.nextInt(192));}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void chunkLoad(org.bukkit.event.world.ChunkLoadEvent event){for(var entity:event.getChunk().getEntities())if(managed(entity))entity.remove();}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(managed(entity)){UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey);if(dungeon!=null&&room!=null)cleanupAttempt(new AttemptKey(dungeon,room));}}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(managed(entity)){UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey);if(dungeon!=null&&room!=null)cleanupAttempt(new AttemptKey(dungeon,room),"chunk_unload");}}
|
||||
@Override public CompletionStage<Void> reset(AdventureQuestState.Reset reset){cleanupRuntime();var pending=inFlight.stream().map(future->future.handle((ignored,failure)->null)).toArray(CompletableFuture[]::new);return CompletableFuture.allOf(pending).thenCompose(ignored->dungeons.reset(reset));}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void quit(PlayerQuitEvent event){done(event.getPlayer().getUniqueId());}
|
||||
@Override public synchronized void close(){if(closed)return;if(pulse!=null)pulse.cancel();cleanupRuntime();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();closed=true;playerEdits.clear();dungeonEditors.clear();startingRuns.clear();completing.clear();defeatingDungeons.clear();}
|
||||
|
||||
@@ -25,8 +25,10 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
|
||||
public Optional<DungeonState.Room> roomAt(UUID dungeon,int x,int y,int z){return find(dungeon).flatMap(value->{var matches=value.rooms().stream().filter(room->room.bounds().contains(x,y,z)).toList();return matches.size()==1?Optional.of(matches.getFirst()):Optional.empty();});}
|
||||
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch){return create(name,world,x,y,z,yaw,pitch,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
|
||||
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch,AdventureQuestState.ParticipationMode mode){var value=new DungeonState.Dungeon(UUID.randomUUID(),name,world,new DungeonState.Position(x,y,z,yaw,pitch),false,UUID.randomUUID(),List.of(),mode,DungeonState.Reward.empty(),false);return mutate(state->{var values=new ArrayList<>(state.dungeons());values.add(value);return result(new DungeonState(values,state.runs()),value);});}
|
||||
public CompletableFuture<DungeonState.Dungeon> setMode(UUID id,UUID expectedRevision,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expectedRevision);Objects.requireNonNull(mode);return mutate(state->{var current=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));if(!current.revision().equals(expectedRevision))throw new IllegalArgumentException("Dungeon changed");if(current.participationMode()==mode)return result(state,current);var changed=new DungeonState.Dungeon(current.id(),current.name(),current.world(),current.entrance(),current.enabled(),UUID.randomUUID(),current.rooms(),mode,current.completionReward(),current.rewardConfigured());return result(replace(state,changed),changed);});}
|
||||
public CompletableFuture<Optional<DungeonState.Dungeon>> remove(UUID id){return mutate(state->{var found=find(state,id);if(found.isEmpty())return result(state,Optional.empty());if(found.orElseThrow().rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).anyMatch(boss->boss.character()!=null))throw new IllegalArgumentException("Recover character boss custody first");return result(new DungeonState(state.dungeons().stream().filter(v->!v.id().equals(id)).toList(),state.runs().stream().filter(v->!v.dungeon().equals(id)).toList()),found);});}
|
||||
public record ModeChange(DungeonState.Dungeon before,DungeonState.Dungeon after){}
|
||||
public CompletableFuture<DungeonState.Dungeon> setMode(UUID id,UUID expectedRevision,AdventureQuestState.ParticipationMode mode){return setModeWithEvidence(id,expectedRevision,mode).thenApply(ModeChange::after);}
|
||||
public CompletableFuture<ModeChange> setModeWithEvidence(UUID id,UUID expectedRevision,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expectedRevision);Objects.requireNonNull(mode);return mutate(state->{var current=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));if(!current.revision().equals(expectedRevision))throw new IllegalArgumentException("Dungeon changed");if(current.participationMode()==mode)return result(state,new ModeChange(current,current));var changed=new DungeonState.Dungeon(current.id(),current.name(),current.world(),current.entrance(),current.enabled(),UUID.randomUUID(),current.rooms(),mode,current.completionReward(),current.rewardConfigured());return result(replace(state,changed),new ModeChange(current,changed));});}
|
||||
public CompletableFuture<Optional<DungeonState.Dungeon>> remove(UUID id){return mutate("definition_removed",state->{var found=find(state,id);if(found.isEmpty())return result(state,Optional.empty());if(found.orElseThrow().rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).anyMatch(boss->boss.character()!=null))throw new IllegalArgumentException("Recover character boss custody first");return result(new DungeonState(state.dungeons().stream().filter(v->!v.id().equals(id)).toList(),state.runs().stream().filter(v->!v.dungeon().equals(id)).toList()),found);});}
|
||||
public CompletableFuture<DungeonState.Dungeon> setEntrance(UUID id,double x,double y,double z,float yaw,float pitch){return change(id,dungeon->definition(dungeon,dungeon.rooms(),new DungeonState.Position(x,y,z,yaw,pitch),false));}
|
||||
public CompletableFuture<DungeonState.Room> addCornerRoom(UUID id,String name,int x,int y,int z,int xSize,int zSize,int height){if(xSize==0||Math.abs((long)xSize)>256||zSize==0||Math.abs((long)zSize)>256||height<1||height>128)return CompletableFuture.failedFuture(new IllegalArgumentException("Room dimensions out of range"));int maxX=x+(xSize>0?xSize-1:0),minX=x+(xSize<0?xSize+1:0),maxZ=z+(zSize>0?zSize-1:0),minZ=z+(zSize<0?zSize+1:0);return addRoom(id,name,new DungeonState.Bounds(Math.min(x,minX),y,Math.min(z,minZ),Math.max(x,maxX),y+height-1,Math.max(z,maxZ)));}
|
||||
public CompletableFuture<DungeonState.Room> addRadiusRoom(UUID id,String name,int x,int y,int z,int radius,int height){if(radius<1||radius>128||height<1||height>128)return CompletableFuture.failedFuture(new IllegalArgumentException("Room dimensions out of range"));return addRoom(id,name,new DungeonState.Bounds(x-radius,y,z-radius,x+radius,y+height-1,z+radius));}
|
||||
@@ -36,7 +38,9 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
|
||||
public CompletableFuture<Optional<DungeonState.Spawn>> removeMob(UUID id,UUID roomId,UUID spawn){var found=room(id,roomId).flatMap(value->value.mobs().stream().filter(v->v.id().equals(spawn)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs().stream().filter(v->!v.id().equals(spawn)).toList(),room.effects(),room.reward(),room.boss()),found);}
|
||||
public CompletableFuture<DungeonState.Effect> addEffect(UUID id,UUID roomId,DungeonState.EffectTarget target,String effect,int level,boolean particles){var value=new DungeonState.Effect(UUID.randomUUID(),target,effect,level,particles);return updateRoom(id,roomId,room->{var effects=new ArrayList<>(room.effects());if(effects.size()>=32)throw new IllegalArgumentException("Effect limit reached");effects.add(value);return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),effects,room.reward(),room.boss());},value);}
|
||||
public CompletableFuture<Optional<DungeonState.Effect>> removeEffect(UUID id,UUID roomId,UUID effect){var found=room(id,roomId).flatMap(value->value.effects().stream().filter(v->v.id().equals(effect)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects().stream().filter(v->!v.id().equals(effect)).toList(),room.reward(),room.boss()),found);}
|
||||
public CompletableFuture<DungeonState.Reward> setCompletionReward(UUID id,BigInteger xp,BigInteger coins){var value=new DungeonState.Reward(xp,coins);return mutate(state->{var dungeon=editable(state,id);var changed=new DungeonState.Dungeon(dungeon.id(),dungeon.name(),dungeon.world(),dungeon.entrance(),false,UUID.randomUUID(),dungeon.rooms(),dungeon.participationMode(),value,true);return result(replace(state,changed),value);});}
|
||||
public record CompletionRewardChange(DungeonState.Dungeon before,DungeonState.Dungeon after){}
|
||||
public CompletableFuture<DungeonState.Reward> setCompletionReward(UUID id,BigInteger xp,BigInteger coins){return setCompletionRewardWithEvidence(id,xp,coins).thenApply(change->change.after().completionReward());}
|
||||
public CompletableFuture<CompletionRewardChange> setCompletionRewardWithEvidence(UUID id,BigInteger xp,BigInteger coins){var value=new DungeonState.Reward(xp,coins);return mutate(state->{var dungeon=editable(state,id);var changed=new DungeonState.Dungeon(dungeon.id(),dungeon.name(),dungeon.world(),dungeon.entrance(),false,UUID.randomUUID(),dungeon.rooms(),dungeon.participationMode(),value,true);return result(replace(state,changed),new CompletionRewardChange(dungeon,changed));});}
|
||||
public CompletableFuture<DungeonState.Boss> setBoss(UUID id,UUID roomId,EntityType type,String name,double x,double y,double z,float yaw,float pitch){var value=new DungeonState.Boss(type,name,new DungeonState.Position(x,y,z,yaw,pitch));return updateRoom(id,roomId,room->{if(room.boss()!=null&&room.boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),value);},value);}
|
||||
public CompletableFuture<Boolean> clearBoss(UUID id,UUID roomId){return updateRoom(id,roomId,room->{if(room.boss()!=null&&room.boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),null);},true);}
|
||||
public CompletableFuture<DungeonState.CharacterBoss> assignCharacterBoss(UUID id,UUID roomId,CharacterRecord character,BigDecimal multiplier){Objects.requireNonNull(character);var template=new DungeonState.CharacterBoss(UUID.randomUUID(),character,multiplier,null);return mutate(state->{var dungeon=editable(state,id);if(state.dungeons().stream().flatMap(v->v.rooms().stream()).map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(custody->custody.character().id().equals(character.id())))throw new IllegalArgumentException("Character already assigned");var room=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst().orElseThrow(()->new IllegalArgumentException("Room not found"));if(room.boss()==null||room.boss().character()!=null)throw new IllegalArgumentException("Configure one native boss room first");var value=new DungeonState.CharacterBoss(template.operation(),character,template.multiplier(),room.boss().type(),room.boss().name(),null);var boss=new DungeonState.Boss(EntityType.VINDICATOR,character.name(),room.boss().position(),value);var updated=new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),boss);var rooms=dungeon.rooms().stream().map(v->v.id().equals(roomId)?updated:v).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),value);});}
|
||||
@@ -44,15 +48,23 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
|
||||
public CompletableFuture<DungeonState.CharacterBoss> prepareRecovery(UUID id,UUID administrator){return mutate(state->{var dungeon=editable(state,id);var target=dungeon.rooms().stream().filter(room->room.boss()!=null&&room.boss().character()!=null).findFirst().orElseThrow(()->new IllegalArgumentException("Character boss not found"));var custody=target.boss().character();if(custody.recovery()!=null){if(!custody.recovery().administrator().equals(administrator))throw new IllegalArgumentException("Recovery belongs to another administrator");return result(state,custody);}var prepared=new DungeonState.CharacterBoss(custody.operation(),custody.character(),custody.multiplier(),custody.priorType(),custody.priorName(),new DungeonState.Recovery(UUID.randomUUID(),administrator));var boss=new DungeonState.Boss(target.boss().type(),target.boss().name(),target.boss().position(),prepared);var changed=new DungeonState.Room(target.id(),target.name(),target.bounds(),target.mobs(),target.effects(),target.reward(),boss);var rooms=dungeon.rooms().stream().map(room->room.id().equals(target.id())?changed:room).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),prepared);});}
|
||||
public CompletableFuture<Boolean> completeRecovery(UUID id,UUID operation){return mutate(state->{var dungeon=editable(state,id);var target=dungeon.rooms().stream().filter(room->room.boss()!=null&&room.boss().character()!=null&&room.boss().character().recovery()!=null&&room.boss().character().recovery().operation().equals(operation)).findFirst().orElse(null);if(target==null)return result(state,false);var changed=new DungeonState.Room(target.id(),target.name(),target.bounds(),target.mobs(),target.effects(),target.reward(),null);var rooms=dungeon.rooms().stream().map(room->room.id().equals(target.id())?changed:room).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),true);});}
|
||||
public CompletableFuture<DungeonState.Dungeon> enable(UUID id,List<DungeonState.Dungeon> externalEnabled){return mutate(state->{var dungeon=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));validate(dungeon);var conflicts=new ArrayList<DungeonState.Dungeon>();conflicts.addAll(state.dungeons().stream().filter(v->v.enabled()&&!v.id().equals(id)).toList());conflicts.addAll(externalEnabled);for(var other:conflicts)if(other.world().equals(dungeon.world())&&dungeon.rooms().stream().anyMatch(a->other.rooms().stream().anyMatch(b->a.bounds().overlaps(b.bounds()))))throw new IllegalArgumentException("Dungeon overlaps an enabled dungeon");if(dungeon.enabled())return result(state,dungeon);var enabled=definition(dungeon,dungeon.rooms(),dungeon.entrance(),true);return result(replace(state,enabled),enabled);});}
|
||||
public CompletableFuture<Optional<DungeonState.Dungeon>> disable(UUID id){return mutate(state->{var dungeon=find(state,id).orElse(null);if(dungeon==null)return result(state,Optional.empty());var disabled=definition(dungeon,dungeon.rooms(),dungeon.entrance(),false);return result(replace(new DungeonState(state.dungeons(),state.runs().stream().filter(v->!v.dungeon().equals(id)).toList()),disabled),Optional.of(disabled));});}
|
||||
public CompletableFuture<Boolean> clearRun(UUID dungeon,UUID generation){return mutate(state->{var runs=state.runs().stream().filter(v->!(v.dungeon().equals(dungeon)&&v.generation().equals(generation))).toList();if(runs.size()==state.runs().size())return result(state,false);return result(new DungeonState(state.dungeons(),runs),true);});}
|
||||
public CompletableFuture<Optional<DungeonState.Dungeon>> disable(UUID id){return mutate("definition_disabled",state->{var dungeon=find(state,id).orElse(null);if(dungeon==null)return result(state,Optional.empty());var disabled=definition(dungeon,dungeon.rooms(),dungeon.entrance(),false);return result(replace(new DungeonState(state.dungeons(),state.runs().stream().filter(v->!v.dungeon().equals(id)).toList()),disabled),Optional.of(disabled));});}
|
||||
public CompletableFuture<Boolean> clearRun(UUID dungeon,UUID generation){return mutate("offer_closed",state->{var runs=state.runs().stream().filter(v->!(v.dungeon().equals(dungeon)&&v.generation().equals(generation))).toList();if(runs.size()==state.runs().size())return result(state,false);return result(new DungeonState(state.dungeons(),runs),true);});}
|
||||
public CompletableFuture<DungeonState.Run> run(UUID dungeon,UUID generation){return mutate(state->{requireGeneration(generation);var found=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();if(found.isPresent())return result(state,found.orElseThrow());var value=new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),false);var runs=new ArrayList<>(state.runs());runs.add(value);return result(new DungeonState(state.dungeons(),runs),value);});}
|
||||
public Optional<DungeonState.Run> runView(UUID dungeon,UUID generation){return snapshot.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();}
|
||||
public CompletableFuture<DungeonState.Run> defeatSlot(UUID dungeon,UUID generation,UUID slot){return updateRun(dungeon,generation,run->{var defeated=new HashSet<>(run.defeatedSlots());defeated.add(slot);return new DungeonState.Run(run.dungeon(),run.generation(),defeated,run.clearedRooms(),run.unitClaims(),run.characterClaims(),run.defeated());});}
|
||||
public CompletableFuture<DungeonState.Run> markCleared(UUID dungeon,UUID generation,UUID room){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,run.unitClaims(),run.characterClaims(),run.defeated());});}
|
||||
public CompletableFuture<DungeonState.Run> clearRoom(UUID dungeon,UUID generation,UUID room,UUID quest,Set<UUID> characters){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);var units=new HashSet<>(run.unitClaims());units.add(new DungeonState.UnitClaim(room,quest));var claims=new HashSet<>(run.characterClaims());characters.forEach(value->claims.add(new DungeonState.CharacterClaim(room,value)));return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,units,claims,run.defeated());});}
|
||||
public CompletableFuture<DungeonState.Run> defeat(UUID dungeon,UUID generation){return updateRun(dungeon,generation,run->new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true));}
|
||||
@Override public CompletionStage<Void> reset(AdventureQuestState.Reset reset){currentGeneration=reset.generation();return mutate(state->result(new DungeonState(state.dungeons(),List.of()),null));}
|
||||
public CompletableFuture<DungeonState.Run> defeatSlot(UUID dungeon,UUID generation,UUID slot){return updateRun(dungeon,generation,run->{var defeated=new HashSet<>(run.defeatedSlots());defeated.add(slot);return new DungeonState.Run(run.dungeon(),run.generation(),defeated,run.clearedRooms(),run.unitClaims(),run.characterClaims(),run.defeated(),run.completionAudit(),run.completionPublished());});}
|
||||
public CompletableFuture<DungeonState.Run> markCleared(UUID dungeon,UUID generation,UUID room){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,run.unitClaims(),run.characterClaims(),run.defeated(),run.completionAudit(),run.completionPublished());});}
|
||||
public CompletableFuture<DungeonState.Run> clearRoom(UUID dungeon,UUID generation,UUID room,UUID quest,Set<UUID> characters){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);var units=new HashSet<>(run.unitClaims());units.add(new DungeonState.UnitClaim(room,quest));var claims=new HashSet<>(run.characterClaims());characters.forEach(value->claims.add(new DungeonState.CharacterClaim(room,value)));return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,units,claims,run.defeated(),run.completionAudit(),run.completionPublished());});}
|
||||
public CompletableFuture<DungeonState.Run> defeat(UUID dungeon,UUID generation){return defeat(dungeon,generation,Map.of());}
|
||||
public CompletableFuture<DungeonState.Run> defeat(UUID dungeon,UUID generation,Map<String,String> audit){var frozen=Map.copyOf(audit);return updateRun(dungeon,generation,run->run.defeated()?run:new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true,frozen));}
|
||||
/** Acknowledgement is serialized with the run and bound to its committed native boss. */
|
||||
public CompletableFuture<Boolean> acknowledgeCompletion(UUID dungeon,UUID generation,UUID boss){return mutate(state->{var found=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();if(found.isEmpty())return result(state,false);var run=found.orElseThrow();if(!run.defeated()||!boss.toString().equals(run.completionAudit().get("boss_id")))return result(state,false);if(run.completionPublished())return result(state,true);var published=new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true,run.completionAudit(),true);var runs=state.runs().stream().map(value->value==run?published:value).toList();return result(new DungeonState(state.dungeons(),runs),true);});}
|
||||
/** Reconcile prior exported proof before replaying a missing completion, on this I/O worker. */
|
||||
public CompletableFuture<List<DungeonState.Run>> pendingCompletions(java.util.function.Predicate<DungeonState.Run> exported){return mutate(state->{var runs=new ArrayList<DungeonState.Run>();var pending=new ArrayList<DungeonState.Run>();boolean changed=false;for(var run:state.runs()){if(!run.defeated()||run.completionPublished()||run.completionAudit().isEmpty()){runs.add(run);continue;}if(exported.test(run)){runs.add(new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true,run.completionAudit(),true));changed=true;}else{runs.add(run);pending.add(run);}}return result(changed?new DungeonState(state.dungeons(),runs):state,List.copyOf(pending));});}
|
||||
public record ResetCleanup(AdventureQuestState.Reset reset,int runs){}
|
||||
private volatile java.util.function.Consumer<ResetCleanup> resetCleanupObserver=ignored->{};
|
||||
public void attachResetCleanupEvents(java.util.function.Consumer<ResetCleanup> observer){resetCleanupObserver=Objects.requireNonNull(observer);}
|
||||
@Override public CompletionStage<Void> reset(AdventureQuestState.Reset reset){currentGeneration=reset.generation();return mutate("adventure_day_reset",state->result(new DungeonState(state.dungeons(),List.of()),state.runs().size())).thenAccept(count->{try{resetCleanupObserver.accept(new ResetCleanup(reset,count));}catch(RuntimeException ignored){/* Successful source cleanup does not depend on audit. */}});}
|
||||
private <T>CompletableFuture<T> updateRoom(UUID id,UUID roomId,java.util.function.UnaryOperator<DungeonState.Room> operation,T result){return mutate(state->{var dungeon=editable(state,id);var found=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst().orElseThrow(()->new IllegalArgumentException("Room not found"));var updated=operation.apply(found);var rooms=dungeon.rooms().stream().map(v->v.id().equals(roomId)?updated:v).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),result);});}
|
||||
private CompletableFuture<DungeonState.Run> updateRun(UUID dungeon,UUID generation,java.util.function.UnaryOperator<DungeonState.Run> operation){return mutate(state->{requireGeneration(generation);var existing=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst().orElse(new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),false));var updated=operation.apply(existing);var runs=state.runs().stream().filter(v->!(v.dungeon().equals(dungeon)&&v.generation().equals(generation))).collect(java.util.stream.Collectors.toCollection(ArrayList::new));runs.add(updated);return result(new DungeonState(state.dungeons(),runs),updated);});}
|
||||
private CompletableFuture<DungeonState.Dungeon> change(UUID id,java.util.function.UnaryOperator<DungeonState.Dungeon> operation){return mutate(state->{var updated=operation.apply(editable(state,id));return result(replace(state,updated),updated);});}
|
||||
@@ -68,7 +80,15 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
|
||||
private void requireGeneration(UUID generation){var current=currentGeneration;if(current!=null&&!current.equals(generation))throw new IllegalStateException("Stale dungeon generation");}
|
||||
private record Result<T>(DungeonState state,T value){}
|
||||
private static <T>Result<T> result(DungeonState state,T value){return new Result<>(state,value);}
|
||||
private <T>CompletableFuture<T> mutate(java.util.function.Function<DungeonState,Result<T>> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Dungeon directory closed"));return future;}pending++;}worker.execute(()->{try{var changed=operation.apply(snapshot);if(changed.state()!=snapshot){repository.save(changed.state());snapshot=changed.state();}future.complete(changed.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
|
||||
/** Immutable source evidence belongs to its exact acknowledged mutation, not a later directory read. */
|
||||
public record Publication(DungeonState before,DungeonState after){}
|
||||
public record RunRemoval(UUID operation,String reason,Publication publication){}
|
||||
private volatile java.util.function.Consumer<RunRemoval> runRemovalObserver=ignored->{};
|
||||
public void attachRunRemovalEvents(java.util.function.Consumer<RunRemoval> observer){runRemovalObserver=Objects.requireNonNull(observer);}
|
||||
private static final class PublishedMutation<T> extends CompletableFuture<T>{private volatile Publication publication;}
|
||||
public static Optional<Publication> publication(CompletionStage<?> mutation){return mutation instanceof PublishedMutation<?> source?Optional.ofNullable(source.publication):Optional.empty();}
|
||||
private <T>CompletableFuture<T> mutate(java.util.function.Function<DungeonState,Result<T>> operation){return mutate("authoritative_state_transition",operation);}
|
||||
private <T>CompletableFuture<T> mutate(String reason,java.util.function.Function<DungeonState,Result<T>> operation){var future=new PublishedMutation<T>();UUID observation=UUID.randomUUID();synchronized(this){if(closed){future.completeExceptionally(new IOException("Dungeon directory closed"));return future;}pending++;}worker.execute(()->{try{var before=snapshot;var changed=operation.apply(before);if(changed.state()!=before){repository.save(changed.state());snapshot=changed.state();future.publication=new Publication(before,changed.state());if(before.runs().stream().anyMatch(run->changed.state().runs().stream().noneMatch(value->value.dungeon().equals(run.dungeon())&&value.generation().equals(run.generation()))))try{runRemovalObserver.accept(new RunRemoval(observation,reason,future.publication));}catch(RuntimeException ignored){/* A committed source transition cannot depend on its observer. */}}future.complete(changed.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
|
||||
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);}
|
||||
|
||||
@@ -56,8 +56,10 @@ public record DungeonState(List<Dungeon> dungeons,List<Run> runs) {
|
||||
}
|
||||
public record UnitClaim(UUID room,UUID quest) {public UnitClaim {Objects.requireNonNull(room);Objects.requireNonNull(quest);}}
|
||||
public record CharacterClaim(UUID room,UUID character) {public CharacterClaim {Objects.requireNonNull(room);Objects.requireNonNull(character);}}
|
||||
public record Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated) {
|
||||
public Run {Objects.requireNonNull(dungeon);Objects.requireNonNull(generation);defeatedSlots=Set.copyOf(defeatedSlots);clearedRooms=Set.copyOf(clearedRooms);unitClaims=Set.copyOf(unitClaims);characterClaims=Set.copyOf(characterClaims);}
|
||||
public record Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated,Map<String,String> completionAudit,boolean completionPublished) {
|
||||
public Run {Objects.requireNonNull(dungeon);Objects.requireNonNull(generation);defeatedSlots=Set.copyOf(defeatedSlots);clearedRooms=Set.copyOf(clearedRooms);unitClaims=Set.copyOf(unitClaims);characterClaims=Set.copyOf(characterClaims);completionAudit=Map.copyOf(completionAudit);if(completionPublished&&(!defeated||completionAudit.isEmpty()))throw new IllegalArgumentException("Unproven dungeon completion publication");}
|
||||
public Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated,Map<String,String> completionAudit){this(dungeon,generation,defeatedSlots,clearedRooms,unitClaims,characterClaims,defeated,completionAudit,false);}
|
||||
public Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated){this(dungeon,generation,defeatedSlots,clearedRooms,unitClaims,characterClaims,defeated,Map.of(),false);}
|
||||
}
|
||||
public DungeonState {dungeons=List.copyOf(dungeons);runs=List.copyOf(runs);unique(dungeons.stream().map(Dungeon::id).toList());var keys=runs.stream().map(v->v.dungeon()+":"+v.generation()).toList();if(new HashSet<>(keys).size()!=keys.size())throw new IllegalArgumentException("Duplicate dungeon run");}
|
||||
public static DungeonState empty(){return new DungeonState(List.of(),List.of());}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
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.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.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Independent observations; no audit fact is reset authority and no replay calls gameplay. */
|
||||
public final class AdventureDayResetEventBridge implements AutoCloseable {
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final AdventureDayResetFactsStore store;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final Clock clock=Clock.systemUTC();
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-adventure-day-reset-facts"));
|
||||
private final Map<UUID,AdventureDayResetFactsStore.Facts> facts=new LinkedHashMap<>();
|
||||
private final Map<UUID,Map<String,Object>> cleanup=new LinkedHashMap<>();
|
||||
private final Set<UUID> handedOff=new java.util.HashSet<>();
|
||||
private final Set<UUID> recoveredPending=new java.util.HashSet<>();
|
||||
private final Map<UUID,Integer> participantRuns=new LinkedHashMap<>();
|
||||
private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
private boolean closed;
|
||||
public AdventureDayResetEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,Path data,Consumer<Throwable> failures){
|
||||
this(sink,data,failures,CompletableFuture.completedFuture(null));
|
||||
}
|
||||
public AdventureDayResetEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,Path data,Consumer<Throwable> failures,CompletionStage<Void> previousDrain){
|
||||
this.failures=failures;store=new AdventureDayResetFactsStore(data);
|
||||
outbox=new FeatureEventOutbox(data,"adventure-day-reset-event-intents",Set.of("adventure.day.reset"),event->sink.apply(event).whenComplete((delivery,failure)->{
|
||||
if(failure==null&&delivery!=LocalEventLog.Delivery.REJECTED)submit(()->{store.remove(event.actionId());facts.remove(event.actionId());cleanup.remove(event.actionId());handedOff.remove(event.actionId());recoveredPending.remove(event.actionId());participantRuns.remove(event.actionId());});
|
||||
}),failures);
|
||||
submit(()->{previousDrain.toCompletableFuture().join();load();});
|
||||
}
|
||||
public void prepared(AdventureQuestDirectory.ResetPublication publication){submit(()->{
|
||||
var reset=publication.reset();if(facts.containsKey(reset.operation()))return;var values=publicationData(publication);var work=cleanup.get(reset.operation());if(work!=null)values.putAll(work);if(participantRuns.containsKey(reset.operation())){values.put("cleaned_run_count",participantRuns.get(reset.operation()));values.put("run_cleanup_capture_quality","acknowledged_dungeon_source_save");}
|
||||
put(new AdventureDayResetFactsStore.Facts(reset.operation(),reset.generation(),reset.startedAt(),publication.actor()==null?null:publication.actor().context(),values,null));
|
||||
});}
|
||||
public void cleanupObserved(AdventureQuestState.Reset reset,int encounters,int participants){submit(()->{
|
||||
var prior=facts.get(reset.operation());boolean unknown=recoveredPending.contains(reset.operation())&&(prior==null||!prior.data().containsKey("cleaned_encounter_count"));
|
||||
var original=cleanup.computeIfAbsent(reset.operation(),ignored->Map.of(unknown?"recovery_cleaned_encounter_count":"cleaned_encounter_count",encounters,"participant_count",participants,"cleanup_capture_quality",unknown?"recovery_current_work_only":"native_work_observed_before_cleanup"));
|
||||
if(prior!=null&&prior.completedAt()==null){var values=new LinkedHashMap<>(prior.data());values.putAll(original);put(withData(prior,values));}
|
||||
});}
|
||||
public void participantCleanup(games.dmg.fantasystick.dungeon.DungeonDirectory.ResetCleanup observed){submit(()->{
|
||||
var operation=observed.reset().operation();participantRuns.putIfAbsent(operation,observed.runs());var prior=facts.get(operation);if(prior==null||prior.completedAt()!=null)return;
|
||||
var values=new LinkedHashMap<>(prior.data());boolean unknown=recoveredPending.contains(operation)&&!values.containsKey("cleaned_run_count");
|
||||
values.putIfAbsent(unknown?"recovery_cleaned_run_count":"cleaned_run_count",participantRuns.get(operation));values.putIfAbsent("run_cleanup_capture_quality",unknown?"recovery_current_work_only":"acknowledged_dungeon_source_save");put(withData(prior,values));
|
||||
});}
|
||||
public void cleanupAcknowledged(AdventureQuestState.Reset reset,int participants){submit(()->{
|
||||
var prior=facts.get(reset.operation());if(prior==null||prior.completedAt()!=null)return;var values=new LinkedHashMap<>(prior.data());
|
||||
values.put("participant_count",participants);values.put("participant_outcomes",participantOutcomes(participants));put(withData(prior,values));
|
||||
});}
|
||||
/** Only called following the final successful source save, not initial installation or cleanup alone. */
|
||||
public void completed(AdventureQuestState.Reset reset){submit(()->complete(reset.operation(),"original_final_source_acknowledgment"));}
|
||||
/** A cleared source reset marker at the same captured generation proves final publication, not a new reset. */
|
||||
public void recover(AdventureQuestState state){submit(()->{
|
||||
load();
|
||||
if(state.reset()!=null&&!facts.containsKey(state.reset().operation())){
|
||||
var reset=state.reset();var values=new LinkedHashMap<String,Object>();values.put("operation_id",reset.operation().toString());values.put("catalog_id",reset.generation().toString());values.put("generation_id",reset.generation().toString());values.put("reset_started_at",reset.startedAt().toString());
|
||||
if(state.catalog()!=null)catalogData(values,state.catalog());values.put("reason","unavailable");values.put("actor_kind","unavailable");values.put("original_capture_quality","unavailable");values.put("previous_capture_quality","unavailable");
|
||||
recoveredPending.add(reset.operation());put(new AdventureDayResetFactsStore.Facts(reset.operation(),reset.generation(),reset.startedAt(),null,values,null));
|
||||
}
|
||||
for(var value:java.util.List.copyOf(facts.values())){
|
||||
if(value.completedAt()!=null)handoff(value);
|
||||
else if(state.reset()==null&&state.catalog()!=null&&state.catalog().revision().equals(value.generation()))complete(value.operation(),"recovered_final_source_marker");
|
||||
}
|
||||
});}
|
||||
private void complete(UUID operation,String quality){
|
||||
var prior=facts.get(operation);if(prior==null)return;if(prior.completedAt()!=null){handoff(prior);return;}
|
||||
var values=new LinkedHashMap<>(prior.data());preservationPolicy(values);values.put("outcome","acknowledged");values.put("completion_capture_quality",quality);
|
||||
values.put("all_current_participants_acknowledged",true);
|
||||
if(!values.containsKey("participant_outcomes")){
|
||||
if(quality.equals("original_final_source_acknowledgment")&&values.get("participant_count") instanceof Integer count)values.put("participant_outcomes",participantOutcomes(count));
|
||||
else values.put("participant_outcome_capture_quality","source_final_marker_original_details_unavailable");
|
||||
}
|
||||
var completed=new AdventureDayResetFactsStore.Facts(prior.operation(),prior.generation(),prior.started(),prior.context(),values,clock.instant());put(completed);handoff(completed);
|
||||
}
|
||||
private void handoff(AdventureDayResetFactsStore.Facts value){
|
||||
if(value.completedAt()==null||!handedOff.add(value.operation()))return;
|
||||
UUID id=UUID.nameUUIDFromBytes(("fantasystick:adventure-day-reset:"+value.operation()+":completed").getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id,value.operation(),value.completedAt(),"adventure.day.reset",value.context(),value.data()));
|
||||
}
|
||||
private void load()throws java.io.IOException{
|
||||
for(var value:store.load()){
|
||||
if(facts.putIfAbsent(value.operation(),value)==null&&value.completedAt()==null)recoveredPending.add(value.operation());
|
||||
if(value.data().get("cleaned_run_count") instanceof Integer count)participantRuns.putIfAbsent(value.operation(),count);
|
||||
if(value.data().containsKey("cleaned_encounter_count"))cleanup.putIfAbsent(value.operation(),Map.of("cleaned_encounter_count",value.data().get("cleaned_encounter_count"),"participant_count",value.data().get("participant_count"),"cleanup_capture_quality",value.data().get("cleanup_capture_quality")));
|
||||
}
|
||||
}
|
||||
private void put(AdventureDayResetFactsStore.Facts value){facts.put(value.operation(),value);try{store.save(value);}catch(java.io.IOException|RuntimeException failure){report(failure);}}
|
||||
private static AdventureDayResetFactsStore.Facts withData(AdventureDayResetFactsStore.Facts value,Map<String,Object> data){return new AdventureDayResetFactsStore.Facts(value.operation(),value.generation(),value.started(),value.context(),data,value.completedAt());}
|
||||
private static LinkedHashMap<String,Object> publicationData(AdventureQuestDirectory.ResetPublication publication){
|
||||
var reset=publication.reset();var values=new LinkedHashMap<String,Object>();values.put("reason",publication.reason());values.put("operation_id",reset.operation().toString());values.put("generation_id",reset.generation().toString());values.put("reset_started_at",reset.startedAt().toString());values.put("original_capture_quality","original_source_publication");catalogData(values,publication.catalog());
|
||||
var before=publication.previous();if(before!=null){values.put("previous_catalog_id",before.revision().toString());values.put("previous_generation_id",before.revision().toString());values.put("previous_day",before.day().toString());values.put("previous_created_at",before.createdAt().toString());values.put("previous_expires_at",before.expiresAt().toString());}else values.put("previous_capture_quality","unavailable");
|
||||
values.put("expired_quest_count",publication.expiredQuests());if(publication.actor()!=null)values.putAll(publication.actor().captured());else{values.put("actor_kind","system");values.put("location_status","not_applicable");}return values;
|
||||
}
|
||||
private static void catalogData(Map<String,Object> values,AdventureQuestState.Catalog catalog){values.put("catalog_id",catalog.revision().toString());values.put("day",catalog.day().toString());values.put("created_at",catalog.createdAt().toString());values.put("expires_at",catalog.expiresAt().toString());values.put("selected_site_count",catalog.sites().size());values.put("selected_site_ids",catalog.sites().stream().map(UUID::toString).toList());}
|
||||
private static Object participantOutcomes(int count){return java.util.stream.IntStream.range(0,count).mapToObj(index->Map.<String,Object>of("participant_index",(double)index,"outcome","acknowledged")).toList();}
|
||||
private static void preservationPolicy(Map<String,Object> values){for(String key:java.util.List.of("earned_rewards_preserved","earned_trophies_preserved","unlocked_shares_preserved","acknowledged_kill_rewards_preserved","pending_proof_preserved","fishing_stock_preserved","inventory_preserved","active_work_expired","current_generation_gates_reset"))values.put(key,true);}
|
||||
public void replay(){submit(()->{load();for(var value:facts.values())if(value.completedAt()!=null)handoff(value);outbox.replay();});}
|
||||
@FunctionalInterface private interface Work{void run()throws java.io.IOException;}
|
||||
private synchronized void submit(Work action){if(closed)return;try{worker.execute(()->{try{action.run();}catch(java.io.IOException|RuntimeException failure){report(failure);}});}catch(RuntimeException failure){report(failure);}}
|
||||
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){/* Failure reporting is not gameplay authority. */}}
|
||||
public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
|
||||
@Override public synchronized void close(){if(closed)return;closed=true;worker.execute(()->{outbox.close();outbox.drained().whenComplete((ignored,failure)->{if(failure==null)drained.complete(null);else drained.completeExceptionally(failure);});});worker.shutdown();}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.IOException;
|
||||
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.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Owner-local observation facts, never a gameplay phase, permission or reset receipt. Worker-only I/O. */
|
||||
final class AdventureDayResetFactsStore {
|
||||
record Facts(UUID operation,UUID generation,Instant started,EventInput.Context context,Map<String,Object> data,Instant completedAt){
|
||||
Facts{data=new EventInput(operation,operation,started,"adventure.day.reset",context,data).data();}
|
||||
}
|
||||
private static final int MAX_BYTES=65_536;
|
||||
private final Path directory;
|
||||
AdventureDayResetFactsStore(Path data){directory=data.toAbsolutePath().resolve("adventure-day-reset-facts");}
|
||||
List<Facts> load()throws IOException{
|
||||
if(!Files.exists(directory,LinkOption.NOFOLLOW_LINKS))return List.of();safeDirectory();var result=new ArrayList<Facts>();
|
||||
try(var paths=Files.list(directory)){for(var path:paths.filter(value->value.getFileName().toString().matches("[0-9a-f-]{36}\\.facts")).sorted().toList()){
|
||||
if(!Files.isRegularFile(path,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe reset facts file");
|
||||
byte[] bytes;try(var input=Files.newInputStream(path,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Oversized reset facts");
|
||||
try{
|
||||
var root=JsonParser.parseString(new String(bytes,StandardCharsets.UTF_8)).getAsJsonObject();if(!text(root,"format").equals("adventure_day_reset_facts_v1"))throw new IOException("Unknown reset facts format");
|
||||
UUID operation=UUID.fromString(text(root,"operation"));if(!path.getFileName().toString().equals(operation+".facts"))throw new IOException("Reset facts identity mismatch");
|
||||
EventInput.Context context=null;if(root.has("context")){
|
||||
var value=root.getAsJsonObject("context");EventInput.Location location=null;
|
||||
if(value.has("world_id"))location=new EventInput.Location(UUID.fromString(text(value,"world_id")),text(value,"world_name"),value.get("x").getAsDouble(),value.get("y").getAsDouble(),value.get("z").getAsDouble(),Instant.parse(text(value,"captured_at")),text(value,"quality"));
|
||||
context=new EventInput.Context(UUID.fromString(text(value,"player_id")),text(value,"player_name"),uuid(value,"character_id"),value.has("character_name")?text(value,"character_name"):null,uuid(value,"session_id"),location);
|
||||
}
|
||||
result.add(new Facts(operation,UUID.fromString(text(root,"generation")),Instant.parse(text(root,"started_at")),context,object(root.getAsJsonObject("data"),0),root.has("completed_at")?Instant.parse(text(root,"completed_at")):null));
|
||||
}catch(RuntimeException failure){throw new IOException("Invalid reset observation facts",failure);}
|
||||
}}return List.copyOf(result);
|
||||
}
|
||||
void save(Facts facts)throws IOException{
|
||||
if(Files.exists(directory,LinkOption.NOFOLLOW_LINKS))safeDirectory();else Files.createDirectories(directory);
|
||||
var root=new JsonObject();root.addProperty("format","adventure_day_reset_facts_v1");root.addProperty("operation",facts.operation().toString());root.addProperty("generation",facts.generation().toString());root.addProperty("started_at",facts.started().toString());
|
||||
if(facts.completedAt()!=null)root.addProperty("completed_at",facts.completedAt().toString());
|
||||
if(facts.context()!=null){var c=facts.context();var value=new JsonObject();value.addProperty("player_id",c.playerId().toString());value.addProperty("player_name",c.playerName());
|
||||
if(c.characterId()!=null){value.addProperty("character_id",c.characterId().toString());value.addProperty("character_name",c.characterName());value.addProperty("session_id",c.sessionId().toString());}
|
||||
if(c.location()!=null){var l=c.location();value.addProperty("world_id",l.worldId().toString());value.addProperty("world_name",l.worldName());value.addProperty("x",l.x());value.addProperty("y",l.y());value.addProperty("z",l.z());value.addProperty("captured_at",l.capturedAt().toString());value.addProperty("quality",l.quality());}root.add("context",value);
|
||||
}
|
||||
root.add("data",new Gson().toJsonTree(facts.data()));byte[] bytes=root.toString().getBytes(StandardCharsets.UTF_8);if(bytes.length>MAX_BYTES)throw new IOException("Oversized reset facts");
|
||||
var target=directory.resolve(facts.operation()+".facts");if(Files.exists(target,LinkOption.NOFOLLOW_LINKS)&&!Files.isRegularFile(target,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe reset facts target");
|
||||
var temporary=Files.createTempFile(directory,".reset-facts-",".tmp");try{
|
||||
try(var channel=FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.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);}
|
||||
}
|
||||
void remove(UUID operation)throws IOException{if(!Files.exists(directory,LinkOption.NOFOLLOW_LINKS))return;safeDirectory();if(Files.deleteIfExists(directory.resolve(operation+".facts")))forceDirectory();}
|
||||
private void safeDirectory()throws IOException{if(!Files.isDirectory(directory,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe reset facts directory");}
|
||||
private void forceDirectory()throws IOException{try(var channel=FileChannel.open(directory,StandardOpenOption.READ)){channel.force(true);}}
|
||||
private static String text(JsonObject value,String key){return value.get(key).getAsString();}
|
||||
private static UUID uuid(JsonObject value,String key){return value.has(key)?UUID.fromString(text(value,key)):null;}
|
||||
private static Map<String,Object> object(JsonObject value,int depth)throws IOException{var map=new LinkedHashMap<String,Object>();for(var entry:value.entrySet())map.put(entry.getKey(),decode(entry.getValue(),depth+1));return Map.copyOf(map);}
|
||||
private static Object decode(JsonElement value,int depth)throws IOException{
|
||||
if(depth>8||value.isJsonNull())throw new IOException("Unsafe reset facts value");if(value.isJsonObject())return object(value.getAsJsonObject(),depth);if(value.isJsonArray()){var result=new ArrayList<>();for(var item:value.getAsJsonArray())result.add(decode(item,depth+1));return List.copyOf(result);}
|
||||
var primitive=value.getAsJsonPrimitive();if(primitive.isBoolean())return primitive.getAsBoolean();if(primitive.isString())return primitive.getAsString();
|
||||
if(primitive.isNumber()){String text=primitive.getAsString();if(text.matches("-?[0-9]+"))return Integer.valueOf(text);double number=primitive.getAsDouble();if(!Double.isFinite(number))throw new IOException("Non-finite reset facts number");return number;}
|
||||
throw new IOException("Unsupported reset facts value");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.shop.ArmorCatalog;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Failure-isolated immutable records from acknowledged armor authorities, never gameplay replay. */
|
||||
public final class ArmorEventBridge implements AutoCloseable {
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final java.time.Clock clock = java.time.Clock.systemUTC();
|
||||
public record Actor(String kind, UUID playerId, String playerName, EventInput.Context context) {
|
||||
public Actor(String kind, EventInput.Context context) {
|
||||
this(kind, context == null ? null : context.playerId(), context == null ? null : context.playerName(), context);
|
||||
}
|
||||
}
|
||||
private static void actorData(Map<String, Object> values, Actor actor) {
|
||||
values.put("actor_kind", actor == null ? "unavailable" : actor.kind());
|
||||
values.put("request_actor_kind", actor == null ? "unavailable" : actor.kind());
|
||||
values.put("actor_capture_quality", actor == null || actor.kind().equals("player") && actor.context() == null ? "unavailable" : "native");
|
||||
if (actor != null && actor.playerId() != null) values.put("request_player_id", actor.playerId().toString());
|
||||
if (actor != null && actor.playerName() != null) values.put("request_player_name", actor.playerName());
|
||||
}
|
||||
private final Set<UUID> catalogs = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public ArmorEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Path data, Consumer<Throwable> failures) {
|
||||
outbox = new FeatureEventOutbox(data, "armor-event-intents", Set.of("armor.catalog_refreshed", "armorer.placed", "armorer.removed", "armor.purchased", "config.read", "config.changed"), sink, failures);
|
||||
}
|
||||
|
||||
public void catalogRefreshed(ArmorCatalog catalog) {
|
||||
try {
|
||||
if (!catalogs.add(catalog.revision())) return;
|
||||
var products = new ArrayList<Map<String, String>>();
|
||||
catalog.products().entrySet().stream().sorted(Comparator.comparing(entry ->
|
||||
entry.getKey().slot().name() + "." + entry.getKey().tier().name())).forEach(entry -> {
|
||||
var product = entry.getKey(); var offer = entry.getValue();
|
||||
products.add(Map.of("product_id", product.slot().name().toLowerCase(Locale.ROOT) + "."
|
||||
+ product.tier().name().toLowerCase(Locale.ROOT), "price", offer.price().toString(),
|
||||
"stock", Integer.toString(offer.remaining())));
|
||||
});
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("actor_kind", "system"); values.put("catalog_revision", catalog.revision().toString());
|
||||
values.put("catalog_day", catalog.day().toString()); values.put("refreshed_at", catalog.refreshedAt().toString());
|
||||
values.put("refresh_reason", catalog.reason().name().toLowerCase(Locale.ROOT));
|
||||
values.put("next_midnight", catalog.day().plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant().toString());
|
||||
values.put("products", java.util.List.copyOf(products)); values.put("outcome", "acknowledged");
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:armor:" + catalog.revision() + ":catalog-refreshed")
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id, catalog.revision(), catalog.refreshedAt(),
|
||||
"armor.catalog_refreshed", null, Map.copyOf(values)));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot interrupt the already-saved catalog. */ }
|
||||
}
|
||||
|
||||
public Actor actor(org.bukkit.command.CommandSender sender, games.dmg.fantasystick.session.EquipmentSessions sessions,
|
||||
org.bukkit.Location input) {
|
||||
String kind = sender instanceof org.bukkit.entity.Player ? "player"
|
||||
: sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console";
|
||||
if (!(sender instanceof org.bukkit.entity.Player player)) return new Actor(kind, null);
|
||||
UUID id = null; String name = null; UUID character = null, session = null; String characterName = null;
|
||||
EventInput.Location location = null;
|
||||
try { id = player.getUniqueId(); } catch (RuntimeException ignored) { }
|
||||
try { name = player.getName(); } catch (RuntimeException ignored) { }
|
||||
try {
|
||||
var active = id == null ? null : sessions.active(id).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
if (record != null) { character = record.id(); characterName = record.name(); session = active.session(); }
|
||||
} catch (RuntimeException ignored) { }
|
||||
try {
|
||||
var nativeLocation = input == null ? player.getLocation() : input;
|
||||
var world = nativeLocation.getWorld();
|
||||
if (world != null) location = new EventInput.Location(world.getUID(), world.getName(), nativeLocation.getX(),
|
||||
nativeLocation.getY(), nativeLocation.getZ(), clock.instant(), "native");
|
||||
} catch (RuntimeException ignored) { }
|
||||
try { return new Actor(kind, id, name, new EventInput.Context(id, name, character, characterName, session, location)); }
|
||||
catch (RuntimeException unavailable) { return new Actor(kind, id, name, null); }
|
||||
}
|
||||
|
||||
public void armorerChanged(String type, UUID operation, games.dmg.fantasystick.shop.FantasyArmorer shop,
|
||||
Actor actor, games.dmg.fantasystick.shop.FantasyArmorerController.Observed entities,
|
||||
String outcome) {
|
||||
try {
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
actorData(values, actor);
|
||||
values.put("operation_id", operation.toString()); values.put("armorer_id", shop.id().toString());
|
||||
values.put("armorer_name", shop.name());
|
||||
values.put("revision_capture_quality", shop.revision() == null ? "unavailable" : "persisted");
|
||||
if (shop.revision() != null) values.put("armorer_revision", shop.revision().toString());
|
||||
values.put("world_id", shop.world().toString()); values.put("x", shop.x()); values.put("y", shop.y());
|
||||
values.put("z", shop.z()); values.put("yaw", (double) shop.yaw()); values.put("pitch", (double) shop.pitch());
|
||||
values.put("chunk_status", entities.chunkStatus()); values.put("entity_capture_quality", entities.quality());
|
||||
values.put("entity_ids", entities.ids().stream().map(UUID::toString).toList());
|
||||
if (entities.ids().size() == 1) values.put("entity_id", entities.ids().getFirst().toString());
|
||||
values.put("entity_outcome", outcome); values.put("outcome", "acknowledged");
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:armor:" + operation + ":" + type).getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id, operation, clock.instant(), type,
|
||||
actor == null ? null : actor.context(), Map.copyOf(values)));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot undo native registration/reconciliation. */ }
|
||||
}
|
||||
|
||||
public void purchased(games.dmg.fantasystick.shop.ArmorMarket.PurchaseProof proof, UUID catalog,
|
||||
games.dmg.fantasystick.shop.FantasyArmorer shop, games.dmg.fantasystick.shop.ArmorProduct product,
|
||||
Actor actor, games.dmg.fantasystick.persistence.CharacterRecord before,
|
||||
games.dmg.fantasystick.persistence.CharacterRecord after) {
|
||||
try {
|
||||
// Bind the audit to the real consume-once receipt, never a reconstructed operation or duplicate resolution.
|
||||
if (!after.rewardReceipts().contains(proof.operation()) || before.rewardReceipts().contains(proof.operation())) return;
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
actorData(values, actor);
|
||||
values.put("purchase_operation_id", proof.operation().toString()); values.put("receipt_id", proof.operation().toString());
|
||||
values.put("catalog_revision", catalog.toString()); values.put("armorer_id", shop.id().toString());
|
||||
values.put("revision_capture_quality", shop.revision() == null ? "unavailable" : "persisted");
|
||||
if (shop.revision() != null) values.put("armorer_revision", shop.revision().toString());
|
||||
values.put("product_id", (product.slot().name() + "." + product.tier().name()).toLowerCase(Locale.ROOT));
|
||||
values.put("slot", product.slot().name().toLowerCase(Locale.ROOT));
|
||||
values.put("previous_tier", before.gear().tier(product.slot()).name().toLowerCase(Locale.ROOT));
|
||||
values.put("new_tier", after.gear().tier(product.slot()).name().toLowerCase(Locale.ROOT));
|
||||
values.put("price", proof.price().toString()); values.put("balance_before", before.coins().toString());
|
||||
values.put("balance_after", after.coins().toString()); values.put("stock_before", Integer.toString(proof.stockBefore()));
|
||||
values.put("stock_after", Integer.toString(proof.stockAfter()));
|
||||
values.put("before_revision", before.revision().toString()); values.put("after_revision", after.revision().toString());
|
||||
if (actor != null && actor.context() != null && actor.context().sessionId() != null)
|
||||
values.put("session_id", actor.context().sessionId().toString());
|
||||
values.put("location_capture_quality", actor != null && actor.context() != null && actor.context().location() != null ? "native" : "unavailable");
|
||||
values.put("outcome", "acknowledged");
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:armor:" + proof.operation() + ":purchased").getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id, proof.operation(), clock.instant(), "armor.purchased",
|
||||
actor == null ? null : actor.context(), Map.copyOf(values)));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot change saved coins, stock or native gear. */ }
|
||||
}
|
||||
|
||||
public void configRead(Actor actor, String key, String value) {
|
||||
configuration("config.read", actor, key, null, value);
|
||||
}
|
||||
public void configChanged(Actor actor, String key, String before, String after) {
|
||||
configuration("config.changed", actor, key, before, after);
|
||||
}
|
||||
private void configuration(String type, Actor actor, String key, String before, String after) {
|
||||
try {
|
||||
var operation = UUID.randomUUID(); var values = new LinkedHashMap<String, Object>();
|
||||
actorData(values, actor); values.put("key", key); values.put("after", after);
|
||||
if (before != null) values.put("before", before);
|
||||
values.put("application", "next_armor_restock"); values.put("application_scope", "next_catalog");
|
||||
values.put("outcome", "acknowledged");
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:armor:" + operation + ":" + type).getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id, operation, clock.instant(), type,
|
||||
actor == null ? null : actor.context(), Map.copyOf(values)));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot read/write effective gameplay policy or restock. */ }
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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;
|
||||
|
||||
/** Immutable camp-owner observations. Audit availability and replay never authorize gameplay. */
|
||||
public final class CampEventBridge implements AutoCloseable {
|
||||
public record Actor(EventInput.Context context, Map<String,Object> captured) {
|
||||
public Actor { captured=Map.copyOf(captured); }
|
||||
}
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock=Clock.systemUTC();
|
||||
|
||||
public CampEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Path data,EquipmentSessions sessions,Consumer<Throwable> failures) {
|
||||
this.sessions=java.util.Objects.requireNonNull(sessions);
|
||||
outbox=new FeatureEventOutbox(data,"camp-event-intents",Set.of("adventure.site.registered","adventure.site.mode_changed","adventure.site.removed","adventure.quest_giver.placed","adventure.quest_giver.removed","adventure.catalog_refreshed","adventure.quest.accepted","adventure.quest.abandoned","adventure.quest.expired","adventure.camp.started","adventure.camp.cleared","adventure.camp.reset","config.read","config.changed"),sink,failures);
|
||||
}
|
||||
|
||||
/** Capture independent request facts on the calling server thread, not on the persistence worker. */
|
||||
public Actor actor(CommandSender sender,Location input) {
|
||||
var values=new LinkedHashMap<String,Object>();
|
||||
values.put("actor_kind",sender instanceof Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console");
|
||||
if(!(sender instanceof Player player))return new Actor(null,values);
|
||||
UUID id=null,character=null,session=null; String name=null,characterName=null;
|
||||
EventInput.Location location=null;
|
||||
try{id=player.getUniqueId();if(id!=null)values.put("request_player_id",id.toString());}catch(RuntimeException ignored){}
|
||||
try{name=player.getName();if(name!=null)values.put("request_player_name",name);}catch(RuntimeException ignored){}
|
||||
try{
|
||||
var active=id==null?null:sessions.active(id).filter(sessions::current).orElse(null);
|
||||
var record=active==null?null:sessions.currentRecord(active).orElse(null);
|
||||
if(record!=null){character=record.id();characterName=record.name();session=active.session();
|
||||
values.put("request_character_id",character.toString());values.put("request_session_id",session.toString());}
|
||||
}catch(RuntimeException ignored){}
|
||||
try{
|
||||
var at=input==null?player.getLocation():input;var world=at.getWorld();
|
||||
if(world!=null){
|
||||
values.put("actor_location",Map.of("world_id",world.getUID().toString(),"x",at.getX(),"y",at.getY(),"z",at.getZ()));
|
||||
location=new EventInput.Location(world.getUID(),world.getName(),at.getX(),at.getY(),at.getZ(),clock.instant(),"native");
|
||||
}
|
||||
}catch(RuntimeException ignored){}
|
||||
EventInput.Context context=null;
|
||||
try{context=new EventInput.Context(id,name,character,characterName,session,location);}catch(RuntimeException ignored){}
|
||||
values.put("actor_capture_quality",context==null?"unavailable":"native");
|
||||
if(!values.containsKey("actor_location"))values.put("actor_location_capture_quality","unavailable");
|
||||
return new Actor(context,values);
|
||||
}
|
||||
|
||||
public void siteRegistered(UUID operation,Actor actor,AdventureQuestState.Site site,AdventureQuestState.Arena arena) {
|
||||
if(site.type()!=AdventureQuestState.SiteType.CAMP)return;
|
||||
try{
|
||||
var data=siteData(site,arena);
|
||||
data.put("mode_before","not_applicable");data.put("mode_after",site.participationMode().value());
|
||||
data.put("catalog_impact","unchanged_by_registration");data.put("active_quest_impact","unchanged");
|
||||
accepted("adventure.site.registered",operation,actor,data);
|
||||
}catch(RuntimeException ignored){/* Cannot undo acknowledged registration. */}
|
||||
}
|
||||
|
||||
static LinkedHashMap<String,Object> siteData(AdventureQuestState.Site site,AdventureQuestState.Arena arena) {
|
||||
var data=new LinkedHashMap<String,Object>();
|
||||
data.put("site_kind",site.type().name().toLowerCase(java.util.Locale.ROOT));data.put("site_id",site.id().toString());
|
||||
data.put("site_name",site.name());data.put("site_display_name",site.displayName());
|
||||
data.put("revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted");
|
||||
if(site.registrationRevision()!=null)data.put("site_revision",site.registrationRevision().toString());
|
||||
data.put("world_id",site.world().toString());data.put("x",site.x());data.put("y",site.y());data.put("z",site.z());
|
||||
data.putAll(arenaData(site,arena));
|
||||
return data;
|
||||
}
|
||||
|
||||
public static Map<String,Object> arenaData(AdventureQuestState.Site site,AdventureQuestState.Arena arena) {
|
||||
var data=new LinkedHashMap<String,Object>();var geometry=new LinkedHashMap<String,Object>();
|
||||
geometry.put("shape",arena.shape().name().toLowerCase(java.util.Locale.ROOT));
|
||||
geometry.put("source",site.arena()==null?"configured_default":"explicit");
|
||||
geometry.put("x_blocks",arena.xBlocks());geometry.put("z_blocks",arena.zBlocks());
|
||||
if(arena.shape()==AdventureQuestState.ArenaShape.CIRCLE)geometry.put("radius",arena.xBlocks());
|
||||
data.put("arena",Map.copyOf(geometry));
|
||||
data.put("arena_revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted_site_definition");
|
||||
if(site.registrationRevision()!=null)data.put("arena_revision",site.registrationRevision().toString());
|
||||
data.put("arena_definition",site.arena()==null?"effective_legacy_fallback":"persisted");
|
||||
return data;
|
||||
}
|
||||
|
||||
public void siteModeChanged(UUID operation,Actor actor,AdventureQuestState.Site requestedBefore,
|
||||
AdventureQuestState.Site acknowledged,AdventureQuestState.Arena arena) {
|
||||
try{
|
||||
if(acknowledged.type()!=AdventureQuestState.SiteType.CAMP)return;
|
||||
var data=siteData(acknowledged,arena);
|
||||
data.put("mode_before",requestedBefore.participationMode().value());data.put("mode_after",acknowledged.participationMode().value());
|
||||
if(requestedBefore.registrationRevision()!=null)data.put("requested_site_revision",requestedBefore.registrationRevision().toString());
|
||||
data.put("application_scope","future_acceptances");data.put("active_quest_impact","captured_authority_unchanged");
|
||||
data.put("catalog_impact","same_offer_future_acceptances_use_new_mode");
|
||||
accepted("adventure.site.mode_changed",operation,actor,data);
|
||||
}catch(RuntimeException ignored){/* Never alter mode publication. */}
|
||||
}
|
||||
|
||||
public void siteRemoved(UUID operation,Actor actor,AdventureQuestState.Site removed,
|
||||
AdventureQuestState.Arena arena,AdventureQuestState.Catalog catalog) {
|
||||
try{
|
||||
if(removed.type()!=AdventureQuestState.SiteType.CAMP)return;
|
||||
var data=siteData(removed,arena);
|
||||
data.put("mode_before",removed.participationMode().value());data.put("mode_after","not_applicable");
|
||||
boolean offered=catalog!=null&&catalog.sites().contains(removed.id());
|
||||
data.put("offered_at_acknowledgement",offered);
|
||||
if(catalog!=null)data.put("catalog_id",catalog.revision().toString());
|
||||
data.put("catalog_impact",offered?"slot_unavailable_until_reset":"unoffered_registration_removed");
|
||||
data.put("active_quest_impact","unfinished_site_quests_expired");data.put("earned_reward_impact","retained");
|
||||
accepted("adventure.site.removed",operation,actor,data);
|
||||
}catch(RuntimeException ignored){/* Never alter removal. */}
|
||||
}
|
||||
|
||||
public void giverMutation(String type,UUID operation,Actor actor,AdventureQuestState.Giver giver,
|
||||
games.dmg.fantasystick.adventure.AdventureQuestController.ObservedGiver before,
|
||||
games.dmg.fantasystick.adventure.AdventureQuestController.ObservedGiver after,boolean nativeReturned) {
|
||||
try{
|
||||
var data=new LinkedHashMap<String,Object>();
|
||||
data.put("giver_id",giver.id().toString());data.put("giver_name",giver.name());data.put("giver_display_name",giver.displayName());
|
||||
data.put("revision_capture_quality",giver.registrationRevision()==null?"unavailable":"persisted");
|
||||
if(giver.registrationRevision()!=null)data.put("giver_revision",giver.registrationRevision().toString());
|
||||
data.put("world_id",giver.world().toString());data.put("x",giver.x());data.put("y",giver.y());data.put("z",giver.z());
|
||||
data.put("yaw",(double)giver.yaw());data.put("pitch",(double)giver.pitch());
|
||||
boolean placing=type.equals("adventure.quest_giver.placed");var observed=placing?after:before;
|
||||
data.put("entity_ids",observed.ids().stream().map(UUID::toString).toList());
|
||||
if(observed.ids().size()==1)data.put("entity_id",observed.ids().getFirst().toString());
|
||||
data.put("chunk_status",observed.chunkStatus());data.put("entity_capture_quality",observed.quality());
|
||||
data.put("entity_ids_after",after.ids().stream().map(UUID::toString).toList());
|
||||
String outcome=!nativeReturned?"failed":!"loaded".equals(after.chunkStatus())?"not_loaded":!"native".equals(after.quality())?"unavailable":
|
||||
placing?(after.ids().size()==1?"materialized":"not_materialized"):
|
||||
("native".equals(before.quality())&&after.ids().isEmpty()?"removed":"not_confirmed_removed");
|
||||
data.put("entity_outcome",outcome);data.put("catalog_impact","unchanged");data.put("active_quest_impact","unchanged");
|
||||
accepted(type,operation,actor,data);
|
||||
}catch(RuntimeException ignored){/* Registration acknowledgement and native outcomes remain separate. */}
|
||||
}
|
||||
|
||||
public static boolean ownsSetting(String key){return switch(key){
|
||||
case games.dmg.fantasystick.persistence.AdventureQuestSettings.DAILY_COUNT,games.dmg.fantasystick.persistence.AdventureQuestSettings.RADIUS,
|
||||
games.dmg.fantasystick.persistence.AdventureQuestSettings.RESET_SECONDS,games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_ENEMIES,
|
||||
games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_REWARD_XP,games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_REWARD_COINS->true;
|
||||
default->false;};}
|
||||
private static String settingScope(String key){return switch(key){
|
||||
case games.dmg.fantasystick.persistence.AdventureQuestSettings.DAILY_COUNT->"next_catalog";
|
||||
case games.dmg.fantasystick.persistence.AdventureQuestSettings.RADIUS->"new_sites_and_future_legacy_geometry_attempts";
|
||||
case games.dmg.fantasystick.persistence.AdventureQuestSettings.RESET_SECONDS->"future_attempts";
|
||||
case games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_ENEMIES->"future_uncaptured_catalog_day_rosters";
|
||||
case games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_REWARD_XP,games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_REWARD_COINS->"future_acceptances";
|
||||
default->throw new IllegalArgumentException("Not a camp/catalog setting");};}
|
||||
private static LinkedHashMap<String,Object> settingData(String key,String defaults){var data=new LinkedHashMap<String,Object>();
|
||||
data.put("key",key);data.put("default",defaults);data.put("feature","camp_catalog");data.put("application_scope",settingScope(key));return data;}
|
||||
public void configRead(Actor actor,String key,String effective,String defaults){
|
||||
try{if(!ownsSetting(key))return;var data=settingData(key,defaults);data.put("effective",effective);data.put("outcome","acknowledged_read");
|
||||
accepted("config.read",UUID.randomUUID(),actor,data);}catch(RuntimeException ignored){/* Reporting cannot depend on audit. */}
|
||||
}
|
||||
public void configChanged(Actor actor,UUID operation,String key,String before,String after,String defaults){
|
||||
try{if(!ownsSetting(key)||before.equals(after))return;var data=settingData(key,defaults);data.put("before",before);data.put("after",after);
|
||||
data.put("effective",after);data.put("outcome","acknowledged_future_publication");accepted("config.changed",operation,actor,data);
|
||||
}catch(RuntimeException ignored){/* Effective publication is never rolled back or duplicated by audit. */}
|
||||
}
|
||||
|
||||
public record CampAttempt(UUID id,UUID catalog,AdventureQuestState.Site site,AdventureQuestState.Arena arena,int resetSeconds,
|
||||
List<String> roster,List<Map<String,Object>> entities,List<Map<String,Object>> representedUnits,
|
||||
int liveEntities,UUID finalEnemy,Map<String,Object> finalLocation,java.time.Instant defeatedAt,boolean currentGeneration,Map<String,Object> nativePresence,Map<String,Object> activation){
|
||||
public CampAttempt(UUID id,UUID catalog,AdventureQuestState.Site site,AdventureQuestState.Arena arena,int resetSeconds,
|
||||
List<String> roster,List<Map<String,Object>> entities,List<Map<String,Object>> representedUnits,
|
||||
int liveEntities,UUID finalEnemy,Map<String,Object> finalLocation,java.time.Instant defeatedAt,boolean currentGeneration,Map<String,Object> nativePresence){
|
||||
this(id,catalog,site,arena,resetSeconds,roster,entities,representedUnits,liveEntities,finalEnemy,finalLocation,defeatedAt,currentGeneration,nativePresence,Map.of("capture_quality","unavailable"));
|
||||
}
|
||||
public CampAttempt{roster=List.copyOf(roster);entities=entities.stream().map(Map::copyOf).toList();representedUnits=representedUnits.stream().map(Map::copyOf).toList();finalLocation=Map.copyOf(finalLocation);nativePresence=Map.copyOf(nativePresence);activation=Map.copyOf(activation);}
|
||||
}
|
||||
public void attemptTransition(String type,CampAttempt attempt,String reason,boolean succeeded){
|
||||
try{
|
||||
var site=attempt.site();if(site.type()!=AdventureQuestState.SiteType.CAMP)return;
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","system");data.put("site_id",site.id().toString());data.put("site_kind","camp");
|
||||
data.put("site_name",site.name());data.put("catalog_id",attempt.catalog().toString());data.put("generation_id",attempt.catalog().toString());
|
||||
data.put("attempt_id",attempt.id().toString());data.put("attempt_capture_quality","native_transient_not_persisted");
|
||||
data.put("roster_capture_quality","acknowledged_catalog_day_roster");data.put("site_mode_at_start",site.participationMode().value());
|
||||
data.put("site_revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted_definition_at_start");
|
||||
if(site.registrationRevision()!=null)data.put("site_revision",site.registrationRevision().toString());
|
||||
data.put("encounter_location",Map.of("world_id",site.world().toString(),"x",site.x(),"y",site.y(),"z",site.z()));
|
||||
data.put("arena_shape",attempt.arena().shape().name().toLowerCase(java.util.Locale.ROOT));data.put("arena_x_blocks",attempt.arena().xBlocks());data.put("arena_z_blocks",attempt.arena().zBlocks());
|
||||
data.putAll(arenaData(site,attempt.arena()));data.put("activation",attempt.activation());
|
||||
data.put("reset_seconds",attempt.resetSeconds());data.put("reason",reason);
|
||||
String nativeOutcome=succeeded?"succeeded":"failed";
|
||||
String outcome=succeeded?"native_observed":"native_failed";
|
||||
if(type.equals("adventure.camp.started")){
|
||||
data.put("native_materialization",attempt.nativePresence());
|
||||
if(succeeded){var status=attempt.nativePresence().get("status");
|
||||
nativeOutcome="loaded_observed".equals(status)?
|
||||
((Number)attempt.nativePresence().get("remaining_native_entities")).intValue()==attempt.roster().size()
|
||||
&&attempt.entities().size()==attempt.roster().size()?"succeeded":"not_materialized":
|
||||
"not_loaded".equals(status)?"not_loaded":"unavailable";
|
||||
if(!"succeeded".equals(nativeOutcome))outcome="native_"+nativeOutcome;}
|
||||
}
|
||||
if(type.equals("adventure.camp.reset")){
|
||||
data.put("native_cleanup",attempt.nativePresence());
|
||||
if(succeeded){var status=attempt.nativePresence().get("status");
|
||||
nativeOutcome="loaded_observed".equals(status)?((Number)attempt.nativePresence().get("remaining_native_entities")).intValue()==0?"succeeded":"not_confirmed_removed":
|
||||
"not_loaded".equals(status)?"not_loaded":"unavailable";}
|
||||
}
|
||||
data.put("native_outcome",nativeOutcome);
|
||||
data.put("generation_status",attempt.currentGeneration()?"current":"stale");data.put("outcome",outcome);
|
||||
data.put("roster_total",attempt.roster().size());data.put("native_entity_total",attempt.entities().size());data.put("remaining_entity_count",attempt.liveEntities());
|
||||
data.put("represented_unit_total",attempt.representedUnits().size());data.put("reward_owner","existing_trophy_receipt_and_beneficiary_claim");
|
||||
if(attempt.finalEnemy()!=null)data.put("final_enemy_id",attempt.finalEnemy().toString());
|
||||
if(!attempt.finalLocation().isEmpty())data.put("final_enemy_location",attempt.finalLocation());
|
||||
if(attempt.defeatedAt()!=null)data.put("defeated_at",attempt.defeatedAt().toString());
|
||||
int parts=Math.max(1,(Math.max(attempt.roster().size(),Math.max(attempt.entities().size(),attempt.representedUnits().size()))+19)/20);
|
||||
for(int part=0;part<parts;part++){
|
||||
var values=new LinkedHashMap<String,Object>(data);int from=part*20;
|
||||
var roster=new ArrayList<Map<String,Object>>();for(int index=from;index<Math.min(from+20,attempt.roster().size());index++)
|
||||
roster.add(Map.of("roster_index",index,"entity_type",attempt.roster().get(index)));
|
||||
values.put("roster_entries",roster);values.put("native_entities",slice(attempt.entities(),from));
|
||||
var units=slice(attempt.representedUnits(),from);values.put("represented_units",units);
|
||||
values.put("represented_quest_ids",units.stream().map(unit->unit.get("quest_id")).toList());
|
||||
if(parts>1){values.put("part_index",part);values.put("part_count",parts);}
|
||||
accepted(type,attempt.id(),null,values,parts==1?-1:part);
|
||||
}
|
||||
}catch(RuntimeException ignored){/* Immutable observations have no native/gameplay authority. */}
|
||||
}
|
||||
private static <T>List<T> slice(List<T> values,int from){return from>=values.size()?List.of():values.subList(from,Math.min(from+20,values.size()));}
|
||||
|
||||
public void questExpired(games.dmg.fantasystick.adventure.AdventureQuestDirectory.QuestExpiry expiry){
|
||||
questTransition("adventure.quest.expired",expiry.operation(),expiry.actor(),expiry.quest(),expiry.site(),null,expiry.reason());
|
||||
}
|
||||
public void questTransition(String type,UUID operation,Actor actor,AdventureQuestState.Quest quest,
|
||||
AdventureQuestState.Site site,UUID acceptingLeader,String reason){
|
||||
try{
|
||||
if(site==null||site.type()!=AdventureQuestState.SiteType.CAMP)return;
|
||||
var data=new LinkedHashMap<String,Object>();data.put("site_kind","camp");data.put("quest_id",quest.id().toString());
|
||||
data.put("catalog_id",quest.catalog().toString());data.put("site_id",quest.site().toString());data.put("site_name",site.name());
|
||||
data.put("unit_kind",quest.unitType().name().toLowerCase(java.util.Locale.ROOT));data.put("unit_id",quest.unit().toString());
|
||||
data.put("captured_mode",quest.participationMode().value());data.put("deadline",quest.expiresAt().toString());
|
||||
data.put("reason",reason);data.put("reward_xp_pool",quest.rewardXp().toString());data.put("reward_coin_pool",quest.rewardCoins().toString());
|
||||
data.put("site_location",Map.of("world_id",site.world().toString(),"x",site.x(),"y",site.y(),"z",site.z()));
|
||||
data.put("site_revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted_definition_at_transition");
|
||||
if(site.registrationRevision()!=null)data.put("site_revision",site.registrationRevision().toString());
|
||||
boolean accepting=type.equals("adventure.quest.accepted");
|
||||
data.put("accepting_actor_capture_quality",accepting?"captured_request":"unavailable_not_in_saved_quest");
|
||||
if(acceptingLeader!=null)data.put("accepting_leader_character_id",acceptingLeader.toString());
|
||||
if(quest.unitType()==AdventureQuestState.UnitType.PARTY)data.put("party_id",quest.unit().toString());
|
||||
else data.put("solo_character_id",quest.unit().toString());
|
||||
var roster=quest.snapshot().stream().sorted().map(UUID::toString).toList();int parts=Math.max(1,(roster.size()+19)/20);
|
||||
for(int part=0;part<parts;part++){
|
||||
var values=new LinkedHashMap<String,Object>(data);int from=part*20,to=Math.min(from+20,roster.size());
|
||||
values.put("accepted_roster",roster.subList(from,to));
|
||||
if(parts>1){values.put("roster_total",roster.size());values.put("part_index",part);values.put("part_count",parts);}
|
||||
accepted(type,operation,actor,values,parts==1?-1:part);
|
||||
}
|
||||
}catch(RuntimeException ignored){/* Accepted authority and abandonment never depend on audit. */}
|
||||
}
|
||||
|
||||
public void catalogPublished(games.dmg.fantasystick.adventure.AdventureQuestDirectory.CatalogPublication publication){
|
||||
try{
|
||||
var catalog=publication.catalog();var data=new LinkedHashMap<String,Object>();
|
||||
data.put("catalog_id",catalog.revision().toString());data.put("catalog_revision",catalog.revision().toString());
|
||||
data.put("utc_day",catalog.day().toString());data.put("created_at",catalog.createdAt().toString());data.put("next_midnight",catalog.expiresAt().toString());
|
||||
data.put("reason",publication.reason());data.put("publication_boundary","complete_durable_catalog");
|
||||
data.put("settings_capture_quality",publication.originalCapture()?"captured_roll":"unavailable_legacy_capture");
|
||||
if(publication.settings()!=null){var rules=publication.settings();
|
||||
data.put("offer_settings",Map.of("adventure-daily-site-count",rules.dailySiteCount(),"adventure-site-radius",rules.radius(),
|
||||
"adventure-site-reset-seconds",rules.resetSeconds(),"adventure-camp-enemies",rules.campEnemies(),
|
||||
"adventure-camp-reward-xp",rules.campRewardXp().toString(),"adventure-camp-reward-coins",rules.campRewardCoins().toString()));
|
||||
}
|
||||
var selected=new java.util.ArrayList<Map<String,Object>>();
|
||||
for(var id:catalog.sites()){
|
||||
var site=publication.selected().stream().filter(value->value.id().equals(id)).findFirst().orElse(null);
|
||||
var row=new LinkedHashMap<String,Object>();row.put("site_id",id.toString());
|
||||
if(site!=null&&publication.originalCapture()){
|
||||
row.put("site_kind",site.type().name().toLowerCase(java.util.Locale.ROOT));row.put("site_name",site.name());
|
||||
row.put("mode",site.participationMode().value());row.put("definition_capture_quality","captured_roll");
|
||||
row.put("revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted");
|
||||
if(site.registrationRevision()!=null)row.put("site_revision",site.registrationRevision().toString());
|
||||
row.put("world_id",site.world().toString());row.put("x",site.x());row.put("y",site.y());row.put("z",site.z());
|
||||
}else row.put("definition_capture_quality","unavailable_legacy_capture");
|
||||
selected.add(Map.copyOf(row));
|
||||
}
|
||||
data.put("selected_total",selected.size());
|
||||
int count=Math.max(1,(selected.size()+19)/20);
|
||||
for(int part=0;part<count;part++){
|
||||
var values=new LinkedHashMap<String,Object>(data);int from=part*20,to=Math.min(from+20,selected.size());
|
||||
values.put("selected_sites",selected.subList(from,to));
|
||||
if(count>1){values.put("part_index",part);values.put("part_count",count);}
|
||||
accepted("adventure.catalog_refreshed",publication.operation(),publication.actor(),values,count==1?-1:part);
|
||||
}
|
||||
}catch(RuntimeException ignored){/* Catalog identity and sampled authority cannot be rerolled by audit. */}
|
||||
}
|
||||
|
||||
private void accepted(String type,UUID operation,Actor actor,Map<String,Object> input){accepted(type,operation,actor,input,-1);}
|
||||
private void accepted(String type,UUID operation,Actor actor,Map<String,Object> input,int part) {
|
||||
try{
|
||||
var data=new LinkedHashMap<String,Object>(input);
|
||||
if(actor!=null)data.putAll(actor.captured());else data.putIfAbsent("actor_kind","unavailable");
|
||||
data.put("operation_id",operation.toString());data.putIfAbsent("outcome","acknowledged");
|
||||
UUID id=UUID.nameUUIDFromBytes(("fantasystick:camp:"+operation+":"+type+(type.startsWith("adventure.quest.")?":quest:"+input.get("quest_id"):"")+(part<0?"":":part:"+part)).getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id,operation,clock.instant(),type,actor==null?null:actor.context(),data));
|
||||
}catch(RuntimeException ignored){/* Never interrupt or replay gameplay. */}
|
||||
}
|
||||
public void replay(){outbox.replay();}
|
||||
public CompletionStage<Void> drained(){return outbox.drained();}
|
||||
@Override public void close(){outbox.close();}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
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.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Independent champion observations; only immutable audit facts enter the worker/outbox. */
|
||||
public final class ChampionEventBridge implements AutoCloseable {
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock=Clock.systemUTC();
|
||||
public ChampionEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Path data,Consumer<Throwable> failures){
|
||||
outbox=new FeatureEventOutbox(data,"champion-event-intents",Set.of("adventure.site.registered","adventure.site.mode_changed","adventure.site.removed",
|
||||
"adventure.quest.accepted","adventure.quest.abandoned","adventure.quest.expired",
|
||||
"adventure.champion.spawned","adventure.champion.defeated","adventure.champion.reset","config.read","config.changed"),sink,failures);
|
||||
}
|
||||
public void siteRegistered(UUID operation,CampEventBridge.Actor actor,AdventureQuestState.Site site,AdventureQuestState.Arena arena){
|
||||
try{
|
||||
if(site.type()!=AdventureQuestState.SiteType.CHAMPION)return;
|
||||
var data=CampEventBridge.siteData(site,arena);
|
||||
data.put("mode_before","not_applicable");data.put("mode_after",site.participationMode().value());
|
||||
data.put("catalog_impact","unchanged_by_registration");data.put("active_quest_impact","unchanged");
|
||||
accepted("adventure.site.registered",operation,actor,data);
|
||||
}catch(RuntimeException ignored){/* Acknowledged authority is independent of audit availability. */}
|
||||
}
|
||||
public void siteModeChanged(UUID operation,CampEventBridge.Actor actor,AdventureQuestState.Site requestedBefore,
|
||||
AdventureQuestState.Site acknowledged,AdventureQuestState.Arena arena){
|
||||
try{
|
||||
if(acknowledged.type()!=AdventureQuestState.SiteType.CHAMPION)return;
|
||||
var data=CampEventBridge.siteData(acknowledged,arena);
|
||||
data.put("mode_before",requestedBefore.participationMode().value());data.put("mode_after",acknowledged.participationMode().value());
|
||||
if(requestedBefore.registrationRevision()!=null)data.put("requested_site_revision",requestedBefore.registrationRevision().toString());
|
||||
data.put("application_scope","future_acceptances");data.put("active_quest_impact","captured_authority_unchanged");
|
||||
data.put("catalog_impact","same_offer_future_acceptances_use_new_mode");
|
||||
accepted("adventure.site.mode_changed",operation,actor,data);
|
||||
}catch(RuntimeException ignored){/* Mode publication never depends on audit. */}
|
||||
}
|
||||
public void siteRemoved(UUID operation,CampEventBridge.Actor actor,AdventureQuestState.Site removed,
|
||||
AdventureQuestState.Arena arena,AdventureQuestState.Catalog catalog){
|
||||
try{
|
||||
if(removed.type()!=AdventureQuestState.SiteType.CHAMPION)return;
|
||||
var data=CampEventBridge.siteData(removed,arena);
|
||||
data.put("mode_before",removed.participationMode().value());data.put("mode_after","not_applicable");
|
||||
boolean offered=catalog!=null&&catalog.sites().contains(removed.id());data.put("offered_at_acknowledgement",offered);
|
||||
if(catalog!=null)data.put("catalog_id",catalog.revision().toString());
|
||||
data.put("catalog_impact",offered?"slot_unavailable_until_reset":"unoffered_registration_removed");
|
||||
data.put("active_quest_impact","unfinished_site_quests_expired");data.put("earned_reward_impact","retained");
|
||||
accepted("adventure.site.removed",operation,actor,data);
|
||||
}catch(RuntimeException ignored){/* Durable removal cannot be undone or duplicated by audit. */}
|
||||
}
|
||||
public static boolean ownsSetting(String key){return Set.of(
|
||||
games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_HEALTH,games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_DAMAGE,
|
||||
games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_REWARD_XP,games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_REWARD_COINS).contains(key);}
|
||||
private static LinkedHashMap<String,Object> settingData(String key,String defaults){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("key",key);data.put("default",defaults);data.put("feature","champion");
|
||||
data.put("application_scope",key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_HEALTH)
|
||||
||key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_DAMAGE)?"future_attempts":"future_acceptances");return data;
|
||||
}
|
||||
public void configRead(CampEventBridge.Actor actor,String key,String effective,String defaults){
|
||||
try{if(!ownsSetting(key))return;var data=settingData(key,defaults);data.put("effective",effective);data.put("outcome","acknowledged_read");
|
||||
accepted("config.read",UUID.randomUUID(),actor,data);}catch(RuntimeException ignored){/* Reporting cannot depend on audit. */}
|
||||
}
|
||||
public void configChanged(CampEventBridge.Actor actor,UUID operation,String key,String before,String after,String defaults){
|
||||
try{if(!ownsSetting(key)||before.equals(after))return;var data=settingData(key,defaults);data.put("before",before);data.put("after",after);
|
||||
data.put("effective",after);data.put("outcome","acknowledged_future_publication");accepted("config.changed",operation,actor,data);
|
||||
}catch(RuntimeException ignored){/* Effective publication cannot be rolled back or duplicated by audit. */}
|
||||
}
|
||||
public void attemptTransition(String type,CampEventBridge.CampAttempt attempt,Map<String,Object> configuration,String reason,boolean succeeded){
|
||||
try{
|
||||
var site=attempt.site();if(site.type()!=AdventureQuestState.SiteType.CHAMPION)return;
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","system");data.put("site_id",site.id().toString());data.put("site_kind","champion");
|
||||
data.put("site_name",site.name());data.put("catalog_id",attempt.catalog().toString());data.put("generation_id",attempt.catalog().toString());
|
||||
data.put("attempt_id",attempt.id().toString());data.put("attempt_capture_quality","native_transient_not_persisted");
|
||||
data.put("roster_capture_quality","registered_single_vindicator_definition");data.put("site_mode_at_start",site.participationMode().value());
|
||||
data.put("site_revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted_definition_at_start");
|
||||
if(site.registrationRevision()!=null)data.put("site_revision",site.registrationRevision().toString());
|
||||
data.put("encounter_location",Map.of("world_id",site.world().toString(),"x",site.x(),"y",site.y(),"z",site.z()));
|
||||
data.put("arena_shape",attempt.arena().shape().name().toLowerCase(java.util.Locale.ROOT));data.put("arena_x_blocks",attempt.arena().xBlocks());data.put("arena_z_blocks",attempt.arena().zBlocks());
|
||||
data.putAll(CampEventBridge.arenaData(site,attempt.arena()));data.put("activation",attempt.activation());
|
||||
data.put("reset_seconds",attempt.resetSeconds());data.put("reason",reason);data.putAll(configuration);
|
||||
String nativeOutcome=succeeded?"succeeded":"failed";String outcome=succeeded?"native_observed":"native_failed";
|
||||
if(type.equals("adventure.champion.spawned")){
|
||||
data.put("native_materialization",attempt.nativePresence());
|
||||
if(succeeded){var status=attempt.nativePresence().get("status");
|
||||
nativeOutcome="loaded_observed".equals(status)?
|
||||
((Number)attempt.nativePresence().get("remaining_native_entities")).intValue()==1&&attempt.entities().size()==1?"succeeded":"not_materialized":
|
||||
"not_loaded".equals(status)?"not_loaded":"unavailable";
|
||||
if(!"succeeded".equals(nativeOutcome))outcome="native_"+nativeOutcome;
|
||||
}
|
||||
}
|
||||
if(type.equals("adventure.champion.reset")){
|
||||
data.put("native_cleanup",attempt.nativePresence());
|
||||
if(succeeded){var status=attempt.nativePresence().get("status");
|
||||
nativeOutcome="loaded_observed".equals(status)?((Number)attempt.nativePresence().get("remaining_native_entities")).intValue()==0?"succeeded":"not_confirmed_removed":
|
||||
"not_loaded".equals(status)?"not_loaded":"unavailable";}
|
||||
}
|
||||
data.put("native_outcome",nativeOutcome);data.put("generation_status",attempt.currentGeneration()?"current":"stale");data.put("outcome",outcome);
|
||||
data.put("roster_total",attempt.roster().size());data.put("native_entity_total",attempt.entities().size());data.put("remaining_entity_count",attempt.liveEntities());
|
||||
data.put("represented_unit_total",attempt.representedUnits().size());data.put("reward_owner","existing_trophy_receipt_and_beneficiary_claim");
|
||||
if(attempt.finalEnemy()!=null)data.put("final_enemy_id",attempt.finalEnemy().toString());
|
||||
if(!attempt.finalLocation().isEmpty())data.put("final_enemy_location",attempt.finalLocation());if(attempt.defeatedAt()!=null)data.put("defeated_at",attempt.defeatedAt().toString());
|
||||
int parts=Math.max(1,(Math.max(attempt.entities().size(),attempt.representedUnits().size())+19)/20);
|
||||
for(int part=0;part<parts;part++){
|
||||
var values=new LinkedHashMap<String,Object>(data);int from=part*20;
|
||||
values.put("native_entities",slice(attempt.entities(),from));var units=slice(attempt.representedUnits(),from);values.put("represented_units",units);
|
||||
values.put("represented_quest_ids",units.stream().map(unit->unit.get("quest_id")).toList());
|
||||
if(parts>1){values.put("part_index",part);values.put("part_count",parts);}
|
||||
accepted(type,attempt.id(),null,values,parts==1?-1:part);
|
||||
}
|
||||
}catch(RuntimeException ignored){/* Audit facts cannot materialize, clear, reset or reward an encounter. */}
|
||||
}
|
||||
private static <T>java.util.List<T> slice(java.util.List<T> values,int from){return from>=values.size()?java.util.List.of():values.subList(from,Math.min(from+20,values.size()));}
|
||||
public void questExpired(games.dmg.fantasystick.adventure.AdventureQuestDirectory.QuestExpiry expiry){
|
||||
questTransition("adventure.quest.expired",expiry.operation(),expiry.actor(),expiry.quest(),expiry.site(),null,expiry.reason());
|
||||
}
|
||||
public void questTransition(String type,UUID operation,CampEventBridge.Actor actor,AdventureQuestState.Quest quest,
|
||||
AdventureQuestState.Site site,UUID acceptingLeader,String reason){
|
||||
try{
|
||||
if(site==null||site.type()!=AdventureQuestState.SiteType.CHAMPION)return;
|
||||
var data=new LinkedHashMap<String,Object>();data.put("site_kind","champion");data.put("quest_id",quest.id().toString());
|
||||
data.put("catalog_id",quest.catalog().toString());data.put("site_id",quest.site().toString());data.put("site_name",site.name());
|
||||
data.put("unit_kind",quest.unitType().name().toLowerCase(java.util.Locale.ROOT));data.put("unit_id",quest.unit().toString());
|
||||
data.put("captured_mode",quest.participationMode().value());data.put("deadline",quest.expiresAt().toString());
|
||||
data.put("reason",reason);data.put("reward_xp_pool",quest.rewardXp().toString());data.put("reward_coin_pool",quest.rewardCoins().toString());
|
||||
data.put("site_location",Map.of("world_id",site.world().toString(),"x",site.x(),"y",site.y(),"z",site.z()));
|
||||
data.put("site_revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted_definition_at_transition");
|
||||
if(site.registrationRevision()!=null)data.put("site_revision",site.registrationRevision().toString());
|
||||
data.put("accepting_actor_capture_quality",type.equals("adventure.quest.accepted")?"captured_request":"unavailable_not_in_saved_quest");
|
||||
if(acceptingLeader!=null)data.put("accepting_leader_character_id",acceptingLeader.toString());
|
||||
if(quest.unitType()==AdventureQuestState.UnitType.PARTY)data.put("party_id",quest.unit().toString());else data.put("solo_character_id",quest.unit().toString());
|
||||
var roster=quest.snapshot().stream().sorted().map(UUID::toString).toList();int parts=Math.max(1,(roster.size()+19)/20);
|
||||
for(int part=0;part<parts;part++){
|
||||
var values=new LinkedHashMap<String,Object>(data);int from=part*20,to=Math.min(from+20,roster.size());values.put("accepted_roster",roster.subList(from,to));
|
||||
if(parts>1){values.put("roster_total",roster.size());values.put("part_index",part);values.put("part_count",parts);}
|
||||
accepted(type,operation,actor,values,parts==1?-1:part);
|
||||
}
|
||||
}catch(RuntimeException ignored){/* Quest publication never authorizes reward or native replay. */}
|
||||
}
|
||||
private void accepted(String type,UUID operation,CampEventBridge.Actor actor,Map<String,Object> input){accepted(type,operation,actor,input,-1);}
|
||||
private void accepted(String type,UUID operation,CampEventBridge.Actor actor,Map<String,Object> input,int part){
|
||||
try{
|
||||
var data=new LinkedHashMap<String,Object>(input);
|
||||
if(actor!=null)data.putAll(actor.captured());else data.putIfAbsent("actor_kind","unavailable");
|
||||
data.put("operation_id",operation.toString());data.putIfAbsent("outcome","acknowledged");
|
||||
String discriminator=type.startsWith("adventure.quest.")?":"+data.get("quest_id"):"";
|
||||
UUID id=UUID.nameUUIDFromBytes(("fantasystick:champion:"+operation+":"+type+discriminator+(part<0?"":":"+part)).getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id,operation,clock.instant(),type,actor==null?null:actor.context(),data));
|
||||
}catch(RuntimeException ignored){/* Never interrupt or replay gameplay. */}
|
||||
}
|
||||
public void replay(){outbox.replay();}
|
||||
public CompletionStage<Void> drained(){return outbox.drained();}
|
||||
@Override public void close(){outbox.close();}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.dungeon.DungeonState;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
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.command.CommandSender;
|
||||
|
||||
/** Original character-boss observations only; no character, inventory or encounter authority. */
|
||||
public final class CharacterBossEventBridge implements AutoCloseable {
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Function<CommandSender,CampEventBridge.Actor> capture;
|
||||
public CharacterBossEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,Path data,
|
||||
Function<CommandSender,CampEventBridge.Actor> capture,Consumer<Throwable> failures){
|
||||
this.capture=java.util.Objects.requireNonNull(capture);
|
||||
outbox=new FeatureEventOutbox(data,"character-boss-event-intents",Set.of("dungeon.character_boss.assigned","dungeon.character_boss.recovered","action.resolved","dungeon.entity.spawned","dungeon.entity.removed","dungeon.entity.defeated","dungeon.character_boss.target_changed","ability.cast","ability.resolved","combat.damage","combat.healed","effect.applied","effect.ended"),sink,failures);
|
||||
}
|
||||
public record Request(UUID action,Instant observedAt,CampEventBridge.Actor actor){}
|
||||
public Request request(CommandSender sender){CampEventBridge.Actor actor;try{actor=capture.apply(sender);}catch(RuntimeException unavailable){actor=null;}if(actor==null)actor=unavailableActor(sender);return new Request(UUID.randomUUID(),Instant.now(),actor);}
|
||||
private static CampEventBridge.Actor unavailableActor(CommandSender sender){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_details_status","optional_capture_unavailable");data.put("location_status","unavailable");
|
||||
if(sender instanceof org.bukkit.entity.Player player){data.put("actor_kind","player");try{var id=player.getUniqueId();String name;try{name=player.getName();}catch(RuntimeException unavailable){name="unavailable";data.put("actor_name_status","unavailable");}if(name==null){name="unavailable";data.put("actor_name_status","unavailable");}return new CampEventBridge.Actor(new EventInput.Context(id,name,null,null,null,null),data);}catch(RuntimeException unavailable){data.put("actor_identity_status","unavailable");}}
|
||||
else data.put("actor_kind",sender instanceof org.bukkit.command.ConsoleCommandSender?"console":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"unavailable");
|
||||
return new CampEventBridge.Actor(null,data);
|
||||
}
|
||||
public record Facts(Request request,UUID custody,Map<String,Object> data){public Facts{data=Map.copyOf(data);}}
|
||||
public Facts custody(Request request,DungeonState.Dungeon definition,UUID room,DungeonState.CharacterBoss custody){
|
||||
var subject=custody.character();var data=new LinkedHashMap<String,Object>();data.put("dungeon_id",definition.id().toString());data.put("dungeon_name",definition.name());data.put("definition_revision",definition.revision().toString());data.put("room_id",room.toString());data.put("custody_operation_id",custody.operation().toString());data.put("subject_character_id",subject.id().toString());data.put("subject_character_name",subject.name());data.put("subject_character_revision",subject.revision().toString());data.put("subject_sheet_generation",subject.sheetGeneration().toString());data.put("subject_role","custody_template_not_active_actor");data.put("multiplier",custody.multiplier().toPlainString());data.put("prior_entity_type",custody.priorType().name());data.put("prior_entity_name",custody.priorName());var stats=new LinkedHashMap<String,Object>();custody.effectiveStats().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->stats.put(entry.getKey().name(),entry.getValue().toString()));data.put("multiplied_stats",Map.copyOf(stats));if(custody.recovery()!=null)data.put("recovery_operation_id",custody.recovery().operation().toString());return new Facts(request,custody.operation(),data);
|
||||
}
|
||||
public Facts assignment(Request request,DungeonDirectory.Publication publication,UUID dungeon,UUID room,DungeonState.CharacterBoss custody){
|
||||
var after=publication.after().dungeons().stream().filter(value->value.id().equals(dungeon)).findFirst().orElseThrow();var before=publication.before().dungeons().stream().filter(value->value.id().equals(dungeon)).findFirst().orElseThrow();var facts=custody(request,after,room,custody);var data=new LinkedHashMap<>(facts.data());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());var boss=before.rooms().stream().filter(value->value.id().equals(room)).findFirst().orElseThrow().boss();if(boss!=null)data.put("prior_spawn_location",position(before.world(),boss.position()));return new Facts(request,custody.operation(),data);
|
||||
}
|
||||
private static Map<String,Object> position(UUID world,DungeonState.Position at){return Map.of("world_id",world.toString(),"x",at.x(),"y",at.y(),"z",at.z(),"yaw",(double)at.yaw(),"pitch",(double)at.pitch());}
|
||||
/** Native shell subject and original immutable source facts, never an activating-player actor. */
|
||||
public Facts runtime(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,UUID shell,String type,Map<String,Object> location){
|
||||
var request=new Request(UUID.randomUUID(),Instant.now(),null);var facts=custody(request,dungeon,room.id(),room.boss().character());var data=new LinkedHashMap<>(facts.data());data.put("actor_kind","system");data.put("location_status","not_applicable");data.put("generation_id",generation.toString());data.put("catalog_id",generation.toString());data.put("native_entity_id",shell.toString());data.put("entity_type",type);data.put("entity_role","character_boss");data.put("entity_name",room.boss().name());data.put("spawn_location",Map.copyOf(location));data.put("managed_source_id",UUID.nameUUIDFromBytes((dungeon.id()+":"+room.id()+":"+generation+":boss").getBytes(StandardCharsets.UTF_8)).toString());data.put("run_id",UUID.nameUUIDFromBytes(("dungeon-run:"+dungeon.id()+":"+generation).getBytes(StandardCharsets.UTF_8)).toString());data.put("run_identity_quality","persisted_dungeon_generation_composite");return new Facts(request,facts.custody(),data);
|
||||
}
|
||||
public void spawned(Facts facts){emit("dungeon.entity.spawned",UUID.fromString(facts.data().get("native_entity_id").toString()),facts,Map.of("outcome","native_observed","materialization_status","loaded_valid_registered_entity","attempt_capture_quality","native_transient_not_persisted","causation","accepted_dungeon_native_spawn"));}
|
||||
/** Freeze a new original native transition while retaining the original shell's immutable source/subject. */
|
||||
public Facts transition(Facts original,Map<String,Object> details){
|
||||
var data=new LinkedHashMap<>(original.data());data.putIfAbsent("spawn_operation_id",original.request().action().toString());data.putAll(details);return new Facts(new Request(UUID.randomUUID(),Instant.now(),null),original.custody(),data);
|
||||
}
|
||||
public void removed(Facts facts,String reason,String status){emit("dungeon.entity.removed",UUID.fromString(facts.data().get("native_entity_id").toString()),facts,Map.of("reason",reason,"outcome","native_cleanup_observed","cleanup_status",status,"reward_status","non_reward_cleanup","causation","character_shell_native_cleanup"));}
|
||||
public void defeated(Facts facts){emit("dungeon.entity.defeated",UUID.fromString(facts.data().get("native_entity_id").toString()),facts,Map.of("reason","native_boss_completion_committed","outcome","acknowledged","settlement_status","acknowledged_boss_completion","reward_owner","US-085","causation","accepted_native_character_shell_death"));}
|
||||
public void targetChanged(Facts facts){emit("dungeon.character_boss.target_changed",facts.request().action(),facts,Map.of("outcome","accepted","target_status","native_target_confirmed","causation","accepted_character_shell_threat_target"));}
|
||||
public Facts cast(Facts original,Map<String,Object> details){var facts=transition(original,details);var data=new LinkedHashMap<>(facts.data());data.put("cast_id",facts.request().action().toString());return new Facts(facts.request(),facts.custody(),data);}
|
||||
public void casting(Facts facts){emit("ability.cast",facts.request().action(),facts,Map.of("outcome","attempted","causation","ready_equipped_character_boss_slot"));}
|
||||
public void resolved(Facts facts,boolean cooldown,String outcome){emit("ability.resolved",facts.request().action(),facts,Map.of("outcome",outcome,"cooldown_committed",cooldown,"causation","original_native_character_boss_cast"));}
|
||||
public void effectApplied(Facts facts,UUID application,Map<String,Object> details){emit("effect.applied",application,facts,details);}
|
||||
public void effectEnded(Facts facts,UUID application,Map<String,Object> details){emit("effect.ended",application,facts,details);}
|
||||
public void effectEndPending(Facts facts,UUID application,Map<String,Object> details){emit("action.resolved",application,facts,details);}
|
||||
public void damage(Facts facts,Map<String,Object> details){emit("combat.damage",facts.request().action(),facts,details);}
|
||||
public void healed(Facts facts,Map<String,Object> details){emit("combat.healed",facts.request().action(),facts,details);}
|
||||
/** Source acknowledgement alone is not physical consumption; no late sender/native lookup. */
|
||||
public void custodySaved(Facts facts){var phase=UUID.nameUUIDFromBytes((facts.custody()+":custody_saved").getBytes(StandardCharsets.UTF_8));emit("action.resolved",phase,facts,Map.of("phase","custody_saved","outcome","pending_native_consumption","custody_status","durably_assigned","sheet_status","consumption_not_acknowledged","causation","registered_assign_sheet"));}
|
||||
public void assigned(Facts facts){emit("dungeon.character_boss.assigned",facts.custody(),facts,Map.of("outcome","acknowledged","custody_status","durably_assigned","sheet_status","exact_original_consumed","causation","registered_assign_sheet"));}
|
||||
public void rolledBack(Facts facts,boolean acknowledged,DungeonDirectory.Publication publication,String reason){
|
||||
var phase=acknowledged?"assignment_cancelled":"assignment_rollback_pending";var data=new LinkedHashMap<String,Object>();data.put("phase",phase);data.put("outcome",acknowledged?"cancelled":"pending_rollback");data.put("reason",reason);data.put("sheet_status","consumption_not_acknowledged");data.put("custody_status",acknowledged?"assignment_rollback_acknowledged":"rollback_unacknowledged");data.put("rollback_publication_status",publication==null?"unavailable_not_acknowledged":"original_source_publication");data.put("causation","registered_assign_sheet");if(publication!=null){var dungeon=facts.data().get("dungeon_id").toString();publication.after().dungeons().stream().filter(value->value.id().toString().equals(dungeon)).findFirst().ifPresent(value->data.put("rollback_after_revision",value.revision().toString()));}var transition=UUID.nameUUIDFromBytes((facts.custody()+":"+phase+":"+facts.request().action()).getBytes(StandardCharsets.UTF_8));emit("action.resolved",transition,facts,data);
|
||||
}
|
||||
public void recoveryPending(Facts facts,UUID recovery,String reason){var phase=UUID.nameUUIDFromBytes((recovery+":recovery_completion_pending:"+facts.request().action()).getBytes(StandardCharsets.UTF_8));emit("action.resolved",phase,facts,Map.of("phase","recovery_completion_pending","outcome","pending_durable_completion","recovery_operation_id",recovery.toString(),"custody_status","recovery_prepared_completion_unacknowledged","sheet_status","exact_authorized_sheet_delivered","reason",reason,"causation","registered_recover_sheet"));}
|
||||
public void recovered(Facts facts,UUID recovery,DungeonDirectory.Publication publication,String delivery){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("outcome","acknowledged");data.put("custody_status","durably_completed");data.put("sheet_status",delivery);data.put("causation","registered_recover_sheet");data.put("recovery_operation_id",recovery.toString());var dungeon=facts.data().get("dungeon_id").toString();publication.after().dungeons().stream().filter(value->value.id().toString().equals(dungeon)).findFirst().ifPresent(value->data.put("after_revision",value.revision().toString()));emit("dungeon.character_boss.recovered",recovery,facts,data);
|
||||
}
|
||||
private void emit(String type,UUID transition,Facts facts,Map<String,Object> details){
|
||||
var data=new LinkedHashMap<>(facts.data());data.putAll(details);var request=facts.request();data.put("operation_id",request.action().toString());var actor=request.actor();if(actor!=null)data.putAll(actor.captured());else data.putIfAbsent("actor_kind","unavailable");if(actor==null||actor.context()==null){var original=data.get("actor_kind");if(!"console".equals(original)&&!"command_block".equals(original)){data.put("original_actor_kind",java.util.Objects.toString(original,"unavailable"));data.put("actor_kind","system");}data.putIfAbsent("location_status","unavailable");}var id=UUID.nameUUIDFromBytes(("fantasystick:character-boss:"+transition+":"+type).getBytes(StandardCharsets.UTF_8));emitBounded(new EventInput(id,request.action(),request.observedAt(),type,actor==null?null:actor.context(),data));
|
||||
}
|
||||
/** Only large original decimal stats are fragmented; envelope/codec/bound remain unchanged.
|
||||
* Part zero is the sole logical outcome; other rows carry exact linked original digits only.
|
||||
* Independent files are not a cross-crash atomic bundle: consumers must require all parts.
|
||||
*/
|
||||
private void emitBounded(EventInput event){
|
||||
var value=event.data().get("multiplied_stats");if(!(value instanceof Map<?,?> stats)||stats.values().stream().mapToLong(part->part.toString().length()).sum()<=4096){outbox.accepted(event);return;}
|
||||
var fragments=new java.util.ArrayList<Map<String,Object>>();stats.entrySet().stream().sorted(java.util.Comparator.comparing(entry->entry.getKey().toString())).forEach(entry->{var digits=entry.getValue().toString();for(int offset=0;offset<digits.length();offset+=1024)fragments.add(Map.of("stat",entry.getKey().toString(),"offset",offset,"total_digits",digits.length(),"digits",digits.substring(offset,Math.min(digits.length(),offset+1024))));});
|
||||
var base=new LinkedHashMap<>(event.data());base.remove("multiplied_stats");base.put("multiplied_stats_status","exact_linked_original_decimal_parts");base.put("multiplied_stats_count",stats.size());base.put("logical_event_id",event.id().toString());base.put("event_part_count",fragments.size()+1);base.put("parts_required_for_complete_stats",true);
|
||||
var continuation=new LinkedHashMap<String,Object>();for(var key:java.util.List.of("operation_id","logical_event_id","event_part_count","multiplied_stats_status","multiplied_stats_count","parts_required_for_complete_stats","subject_character_id","subject_character_revision","subject_sheet_generation","custody_operation_id","dungeon_id","room_id","generation_id","native_entity_id","multiplier","actor_kind","original_actor_kind"))if(base.containsKey(key))continuation.put(key,base.get(key));
|
||||
for(int index=0;index<=fragments.size();index++){var data=new LinkedHashMap<>(index==0?base:continuation);data.put("event_part_index",index);data.put("payload_part_kind",index==0?"original_facts_and_outcome":"multiplied_stat_digits");if(index>0)data.put("multiplied_stats_fragment",fragments.get(index-1));var id=index==0?event.id():UUID.nameUUIDFromBytes((event.id()+":part:"+index).getBytes(StandardCharsets.UTF_8));outbox.accepted(new EventInput(id,event.actionId(),event.time(),event.type(),event.context(),data));}
|
||||
}
|
||||
public void replay(){outbox.replay();}
|
||||
public CompletionStage<Void> drained(){return outbox.drained();}
|
||||
@Override public void close(){outbox.close();}
|
||||
}
|
||||
@@ -12,15 +12,25 @@ import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Failure-isolated character coin initialization, final-death loss and configuration records. */
|
||||
public final class CoinEventBridge {
|
||||
public final class CoinEventBridge implements AutoCloseable {
|
||||
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);}
|
||||
private final FeatureEventOutbox outbox;
|
||||
public CoinEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);outbox=null;}
|
||||
public CoinEventBridge(java.util.function.Function<EventInput,java.util.concurrent.CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock,java.nio.file.Path data,Consumer<Throwable> failures){
|
||||
this.sink=event->sink.apply(event);this.clock=Objects.requireNonNull(clock);
|
||||
outbox=new FeatureEventOutbox(data,"coin-event-intents",java.util.Set.of("character.coins_initialized","character.coins_lost","config.read","config.changed"),sink,failures);
|
||||
}
|
||||
public void replay(){if(outbox!=null)outbox.replay();}
|
||||
public java.util.concurrent.CompletionStage<Void> drained(){return outbox==null?java.util.concurrent.CompletableFuture.completedFuture(null):outbox.drained();}
|
||||
@Override public void close(){if(outbox!=null)outbox.close();}
|
||||
private void publish(EventInput event){if(outbox!=null)outbox.accepted(event);else sink.accept(event);}
|
||||
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 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{publish(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{publish(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));}
|
||||
}
|
||||
@@ -17,22 +17,49 @@ 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 final class CombatRewardEventBridge implements AutoCloseable {
|
||||
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");}
|
||||
String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool,
|
||||
Map<String,Object> managedEvidence){
|
||||
public 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){
|
||||
this(targetId,targetType,experienceClass,coinClass,killPosition,managedOrigin,stage,defeatedAt,
|
||||
directContributors,rewardUnitId,rewardUnitCount,experiencePool,coinPool,Map.of());
|
||||
}
|
||||
public Capture{managedEvidence=Map.copyOf(managedEvidence);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);}
|
||||
private final FeatureEventOutbox outbox;
|
||||
public CombatRewardEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);outbox=null;}
|
||||
public CombatRewardEventBridge(java.util.function.Function<EventInput,java.util.concurrent.CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock,java.nio.file.Path data,Consumer<Throwable> failures){
|
||||
this.sink=event->sink.apply(event);this.clock=Objects.requireNonNull(clock);
|
||||
outbox=new FeatureEventOutbox(data,"combat-reward-event-intents",java.util.Set.of("combat.reward_resolved"),sink,failures);
|
||||
}
|
||||
public void replay(){if(outbox!=null)outbox.replay();}
|
||||
public java.util.concurrent.CompletionStage<Void> drained(){return outbox==null?java.util.concurrent.CompletableFuture.completedFuture(null):outbox.drained();}
|
||||
@Override public void close(){if(outbox!=null)outbox.close();}
|
||||
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){}
|
||||
if(award==null||award.record().isEmpty()||before==null||award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE)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("target_entity_type",capture.targetType().toUpperCase(java.util.Locale.ROOT));data.put("reward_class",capture.experienceClass());data.put("classifier_version","native_entity_type_v1");data.put("classifier_evidence",Map.of("native_entity_type",capture.targetType().toUpperCase(java.util.Locale.ROOT),"selected_experience_class",capture.experienceClass(),"capture_stage",capture.stage()));if("hard".equals(capture.experienceClass()))data.put("hard_mob_xp",capture.experiencePool().toString());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");if(capture.managedOrigin().kind()==ManagedEncounterOrigin.Kind.DUNGEON&&capture.managedOrigin().source().toString().equals(capture.managedEvidence().get("managed_source_id")))data.putAll(capture.managedEvidence());}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());data.put("location_capture_quality",context==null||context.location()==null?"unavailable":"exact");try{var event=new EventInput(id(operation),operation,clock.instant(),"combat.reward_resolved",context,Map.copyOf(data));if(outbox!=null)outbox.accepted(event);else sink.accept(event);}catch(RuntimeException ignored){}
|
||||
}
|
||||
private EventInput.Context context(EquipmentSessions.Active active,Player player,String characterName){
|
||||
if(active==null||player==null)return null;
|
||||
try{return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),location(player));}
|
||||
catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private EventInput.Location location(Player player){
|
||||
try{
|
||||
var 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 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));}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.dungeon.DungeonState;
|
||||
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.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.command.CommandSender;
|
||||
|
||||
/** Independent authoring observations; neither delivery nor replay authorizes dungeon gameplay. */
|
||||
public final class DungeonAuditEventBridge implements AutoCloseable {
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Function<CommandSender,CampEventBridge.Actor> capture;
|
||||
private final Clock clock=Clock.systemUTC();
|
||||
public DungeonAuditEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,Path data,
|
||||
Function<CommandSender,CampEventBridge.Actor> capture,Consumer<Throwable> failures){
|
||||
this.capture=java.util.Objects.requireNonNull(capture);
|
||||
outbox=new FeatureEventOutbox(data,"dungeon-audit-event-intents",Set.of("dungeon.created","dungeon.mode_changed","dungeon.removed","dungeon.edit.started","dungeon.edit.ended","dungeon.entrance_changed","dungeon.room.created","dungeon.room.removed","dungeon.mob_slot.added","dungeon.mob_slot.removed","dungeon.effect.added","dungeon.effect.removed","dungeon.boss.configured","dungeon.boss.cleared","dungeon.enabled","dungeon.disabled","dungeon.offer.opened","dungeon.offer.closed","adventure.quest.accepted","adventure.quest.abandoned","adventure.quest.expired","dungeon.run.started","dungeon.room.activated","dungeon.entity.spawned","dungeon.entity.defeated","dungeon.entity.removed","dungeon.room.cleared","dungeon.run.closed","effect.applied","effect.ended","config.changed"),sink,failures);
|
||||
}
|
||||
/** Calling-server-thread capture only; workers and replay never look up current senders. */
|
||||
public CampEventBridge.Actor actor(CommandSender sender){try{var original=capture.apply(sender);if(original==null)return null;var facts=new LinkedHashMap<>(original.captured());try{var name=sender.getName();if(name!=null)facts.put("administrator_name",name);else facts.put("administrator_identity_quality","unavailable");}catch(RuntimeException unavailable){facts.put("administrator_identity_quality","unavailable");}return new CampEventBridge.Actor(original.context(),facts);}catch(RuntimeException unavailable){return null;}}
|
||||
public void configChanged(CampEventBridge.Actor actor,UUID operation,String key,String before,String after,String defaultValue){if(!key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.DUNGEON_ROOM_LINK_DISTANCE)||before.equals(after))return;accepted("config.changed",operation,actor,Map.of("feature","dungeon","key",key,"before",before,"after",after,"default",defaultValue,"application_scope","future_definitions_and_offers","active_work_status","unchanged"));}
|
||||
public void created(UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon value){
|
||||
var data=definition(value);data.put("after_revision",value.revision().toString());data.put("after_mode",value.participationMode().value());
|
||||
data.put("after_enabled",value.enabled());data.put("before_definition_status","absent");data.put("reason","administrator_create");
|
||||
accepted("dungeon.created",operation,actor,data);
|
||||
}
|
||||
public void modeChanged(UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon before,DungeonState.Dungeon after){
|
||||
if(before.participationMode()==after.participationMode())return;
|
||||
var data=definition(after);data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());
|
||||
data.put("before_mode",before.participationMode().value());data.put("after_mode",after.participationMode().value());data.put("application_scope","future_acceptances");
|
||||
accepted("dungeon.mode_changed",operation,actor,data);
|
||||
}
|
||||
/** Project only original acknowledged definition changes; reward and character-boss owners stay independent. */
|
||||
public void definitionSaved(UUID operation,CampEventBridge.Actor actor,games.dmg.fantasystick.dungeon.DungeonDirectory.Publication publication,String reason){
|
||||
for(var before:publication.before().dungeons()){
|
||||
var after=publication.after().dungeons().stream().filter(value->value.id().equals(before.id())).findFirst().orElse(null);
|
||||
if(after==null){change("dungeon.removed",operation,actor,before,null,reason,Map.of("cleanup_status","acknowledged_transition"));continue;}
|
||||
if(before.equals(after))continue;
|
||||
if(!before.entrance().equals(after.entrance()))change("dungeon.entrance_changed",operation,actor,before,after,reason,Map.of("before_entrance",position(before.world(),before.entrance()),"after_entrance",position(after.world(),after.entrance())));
|
||||
if(before.enabled()!=after.enabled())change(after.enabled()?"dungeon.enabled":"dungeon.disabled",operation,actor,before,after,reason,Map.of("before_enabled",before.enabled(),"after_enabled",after.enabled(),"cleanup_status",reason.equals("administrator_disable")?"acknowledged_transition":"definition_save_only"));
|
||||
for(var room:before.rooms())if(after.rooms().stream().noneMatch(value->value.id().equals(room.id())))change("dungeon.room.removed",operation,actor,before,after,reason,Map.of("room_id",room.id().toString(),"before_bounds",bounds(room.bounds()),"after_room_status","absent"));
|
||||
for(var room:after.rooms()){
|
||||
var prior=before.rooms().stream().filter(value->value.id().equals(room.id())).findFirst().orElse(null);
|
||||
if(prior==null){change("dungeon.room.created",operation,actor,before,after,reason,Map.of("room_id",room.id().toString(),"after_name",room.displayName(),"after_bounds",bounds(room.bounds()),"before_room_status","absent"));continue;}
|
||||
for(var slot:room.mobs())if(prior.mobs().stream().noneMatch(value->value.id().equals(slot.id())))change("dungeon.mob_slot.added",operation,actor,before,after,reason,slotData(after.world(),room.id(),slot,"after"));
|
||||
for(var slot:prior.mobs())if(room.mobs().stream().noneMatch(value->value.id().equals(slot.id())))change("dungeon.mob_slot.removed",operation,actor,before,after,reason,slotData(before.world(),room.id(),slot,"before"));
|
||||
for(var effect:room.effects())if(prior.effects().stream().noneMatch(value->value.id().equals(effect.id())))change("dungeon.effect.added",operation,actor,before,after,reason,effectData(room.id(),effect,"after"));
|
||||
for(var effect:prior.effects())if(room.effects().stream().noneMatch(value->value.id().equals(effect.id())))change("dungeon.effect.removed",operation,actor,before,after,reason,effectData(room.id(),effect,"before"));
|
||||
if(!java.util.Objects.equals(prior.boss(),room.boss())&&(prior.boss()==null||prior.boss().character()==null)&&(room.boss()==null||room.boss().character()==null)){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("room_id",room.id().toString());data.put("boss_identity_quality","room_and_definition_revision");data.put("before_boss_status",prior.boss()==null?"absent":"configured");data.put("after_boss_status",room.boss()==null?"absent":"configured");
|
||||
if(prior.boss()!=null)bossData(data,before.world(),prior.boss(),"before");if(room.boss()!=null)bossData(data,after.world(),room.boss(),"after");
|
||||
change(room.boss()==null?"dungeon.boss.cleared":"dungeon.boss.configured",operation,actor,before,after,reason,data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public void lease(String type,UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon dungeon,String reason){var data=definition(dungeon);data.put("definition_capture_quality","acknowledged_lease_view");data.put("lease_transition",type.endsWith("started")?"acquired":"released");data.put("reason",reason);data.put("cleanup_status",type.endsWith("started")?"acknowledged_transition":"transient_lease_only");accepted(type,operation,actor,data);}
|
||||
public void offer(String type,UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon dungeon,games.dmg.fantasystick.adventure.AdventureQuestState.Catalog catalog,String reason){var data=definition(dungeon);data.put("catalog_id",catalog.revision().toString());data.put("generation_id",catalog.revision().toString());data.put("expires_at",catalog.expiresAt().toString());data.put("reason",reason);data.put("cleanup_status",type.endsWith("closed")?"acknowledged_transition":"not_applicable");accepted(type,operation,actor,data);}
|
||||
/** Immutable original facts only. Run identity is the existing dungeon/generation composite, not new authority. */
|
||||
public record RuntimeFacts(UUID operation,CampEventBridge.Actor actor,Map<String,Object> data){public RuntimeFacts{data=Map.copyOf(data);}}
|
||||
public RuntimeFacts runtime(UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,Map<String,Object> details){
|
||||
var data=definition(dungeon);data.put("catalog_id",generation.toString());data.put("generation_id",generation.toString());
|
||||
data.put("run_id",UUID.nameUUIDFromBytes(("dungeon-run:"+dungeon.id()+":"+generation).getBytes(StandardCharsets.UTF_8)).toString());data.put("run_identity_quality","persisted_dungeon_generation_composite");
|
||||
if(room!=null){data.put("room_id",room.id().toString());data.put("room_name",room.displayName());data.put("room_bounds",bounds(room.bounds()));}data.putAll(details);return new RuntimeFacts(operation,actor,data);
|
||||
}
|
||||
public void observed(String type,RuntimeFacts facts,Map<String,Object> details){var data=new LinkedHashMap<>(facts.data());data.putAll(details);accepted(type,facts.operation(),facts.actor(),data);}
|
||||
public void questTransition(String type,UUID operation,CampEventBridge.Actor actor,games.dmg.fantasystick.adventure.AdventureQuestState.Quest quest,games.dmg.fantasystick.adventure.AdventureQuestState.Site site,DungeonState.Dungeon definition,String reason){
|
||||
if(site==null||site.type()!=games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.DUNGEON)return;
|
||||
questObserved(type,operation,actor,quest,site,definition,reason);
|
||||
}
|
||||
private void questObserved(String type,UUID operation,CampEventBridge.Actor actor,games.dmg.fantasystick.adventure.AdventureQuestState.Quest quest,games.dmg.fantasystick.adventure.AdventureQuestState.Site site,DungeonState.Dungeon definition,String reason){
|
||||
var data=definition==null?new LinkedHashMap<String,Object>():definition(definition);data.put("dungeon_id",quest.site().toString());data.put("site_capture_quality",site==null?"unavailable_legacy_source_identity_only":"original_acknowledged_site");if(definition==null){if(site!=null)data.put("dungeon_name",site.displayName());data.put("definition_revision_status","unavailable_not_in_captured_site");}data.put("definition_capture_quality",definition==null?"unavailable":"original_request_snapshot");
|
||||
data.put("quest_id",quest.id().toString());data.put("catalog_id",quest.catalog().toString());data.put("generation_id",quest.catalog().toString());data.put("unit_id",quest.unit().toString());data.put("unit_kind",quest.unitType().name().toLowerCase(java.util.Locale.ROOT));data.put("accepted_mode",quest.participationMode().value());data.put("accepted_roster",quest.snapshot().stream().sorted().map(UUID::toString).toList());data.put("expires_at",quest.expiresAt().toString());data.put("reason",reason);
|
||||
var qualified=UUID.nameUUIDFromBytes((operation+":"+quest.id()).getBytes(StandardCharsets.UTF_8));accepted(type,qualified,actor,data);
|
||||
}
|
||||
public void questExpired(games.dmg.fantasystick.adventure.AdventureQuestDirectory.QuestExpiry expired){questObserved("adventure.quest.expired",expired.operation(),expired.actor(),expired.quest(),expired.site(),null,expired.reason());}
|
||||
/** Removed durable source runs, not native absence or a second completion. No current Bukkit/definition lookup. */
|
||||
public void runsRemoved(games.dmg.fantasystick.dungeon.DungeonDirectory.RunRemoval removal){
|
||||
for(var run:removal.publication().before().runs()){
|
||||
if(run.defeated()||removal.publication().after().runs().stream().anyMatch(value->value.dungeon().equals(run.dungeon())&&value.generation().equals(run.generation())))continue;
|
||||
var definition=removal.publication().before().dungeons().stream().filter(value->value.id().equals(run.dungeon())).findFirst().orElse(null);if(definition==null)continue;
|
||||
var operation=UUID.nameUUIDFromBytes((removal.operation()+":"+run.dungeon()+":"+run.generation()).getBytes(StandardCharsets.UTF_8));
|
||||
var facts=runtime(operation,null,definition,null,run.generation(),Map.of("source_operation_id",removal.operation().toString(),"causation_capture_quality","acknowledged_source_transition_original_request_actor_unavailable"));
|
||||
observed("dungeon.run.closed",facts,Map.of("reason",removal.reason(),"run_defeated",false,"reward_status","non_reward_source_cleanup","defeated_slot_ids",run.defeatedSlots().stream().sorted().map(UUID::toString).toList(),"cleared_room_ids",run.clearedRooms().stream().sorted().map(UUID::toString).toList()));
|
||||
}
|
||||
}
|
||||
private void change(String type,UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon before,DungeonState.Dungeon after,String reason,Map<String,Object> details){var data=definition(after==null?before:after);data.put("before_revision",before.revision().toString());if(after!=null)data.put("after_revision",after.revision().toString());else data.put("after_definition_status","absent");data.put("reason",reason);data.putAll(details);accepted(type,operation,actor,data);}
|
||||
private static Map<String,Object> position(UUID world,DungeonState.Position at){return Map.of("world_id",world.toString(),"x",at.x(),"y",at.y(),"z",at.z(),"yaw",(double)at.yaw(),"pitch",(double)at.pitch());}
|
||||
private static Map<String,Object> bounds(DungeonState.Bounds value){return Map.of("min_x",value.minX(),"min_y",value.minY(),"min_z",value.minZ(),"max_x",value.maxX(),"max_y",value.maxY(),"max_z",value.maxZ());}
|
||||
private static Map<String,Object> slotData(UUID world,UUID room,DungeonState.Spawn slot,String prefix){var data=new LinkedHashMap<String,Object>();data.put("room_id",room.toString());data.put("spawn_slot_id",slot.id().toString());data.put(prefix+"_entity_type",slot.type().name());data.put(prefix+"_name",slot.name());data.put(prefix+"_spawn_location",position(world,slot.position()));data.put(prefix+"_quantity","1");return data;}
|
||||
private static Map<String,Object> effectData(UUID room,DungeonState.Effect effect,String prefix){return Map.of("room_id",room.toString(),"effect_id",effect.id().toString(),prefix+"_effect",effect.effect(),prefix+"_target",effect.target().name().toLowerCase(java.util.Locale.ROOT),prefix+"_level",Integer.toString(effect.level()),prefix+"_particles",effect.particles());}
|
||||
private static void bossData(Map<String,Object> data,UUID world,DungeonState.Boss boss,String prefix){data.put(prefix+"_entity_type",boss.type().name());data.put(prefix+"_name",boss.name());data.put(prefix+"_spawn_location",position(world,boss.position()));}
|
||||
private static LinkedHashMap<String,Object> definition(DungeonState.Dungeon value){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("dungeon_id",value.id().toString());data.put("dungeon_name",value.name());
|
||||
data.put("definition_revision",value.revision().toString());data.put("world_id",value.world().toString());
|
||||
var at=value.entrance();data.put("entrance",Map.of("world_id",value.world().toString(),"x",at.x(),"y",at.y(),"z",at.z(),"yaw",(double)at.yaw(),"pitch",(double)at.pitch()));return data;
|
||||
}
|
||||
private void accepted(String type,UUID operation,CampEventBridge.Actor actor,Map<String,Object> input){
|
||||
var data=new LinkedHashMap<>(input);data.put("operation_id",operation.toString());data.putIfAbsent("outcome","acknowledged");
|
||||
if(actor!=null){data.putAll(actor.captured());if(actor.context()==null)data.put("location_status",data.get("actor_kind").equals("player")?"unavailable":"not_applicable");}
|
||||
else{data.put("actor_kind","unavailable");data.put("location_status","unavailable");}
|
||||
// Match the unchanged writer envelope before journaling, retaining original-actor provenance separately.
|
||||
if(actor==null||actor.context()==null){var original=data.get("actor_kind");if(!"console".equals(original)&&!"command_block".equals(original)){data.put("original_actor_kind",java.util.Objects.toString(original,"unavailable"));data.put("actor_kind","system");}}
|
||||
var id=UUID.nameUUIDFromBytes(("fantasystick:dungeon-audit:"+operation+":"+type).getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id,operation,clock.instant(),type,actor==null?null:actor.context(),data));
|
||||
}
|
||||
public void replay(){outbox.replay();}
|
||||
public CompletionStage<Void> drained(){return outbox.drained();}
|
||||
@Override public void close(){outbox.close();}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.dungeon.DungeonState;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Dungeon-specific evidence passed to the shared recoverable feature-event outbox. */
|
||||
public final class DungeonEventBridge {
|
||||
private final FishingEventBridge outbox;
|
||||
|
||||
public DungeonEventBridge(FishingEventBridge outbox) { this.outbox = java.util.Objects.requireNonNull(outbox); }
|
||||
|
||||
public FishingEventBridge.Actor actor(org.bukkit.command.CommandSender sender) { return outbox.actor(sender); }
|
||||
|
||||
public void rewardChanged(UUID operation, FishingEventBridge.Actor actor,
|
||||
DungeonDirectory.CompletionRewardChange change) {
|
||||
var before = change.before();
|
||||
var after = change.after();
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", actor == null ? "system" : actor.kind());
|
||||
if (actor == null) data.put("actor_identity_status", "unavailable");
|
||||
data.put("dungeon_id", after.id().toString());
|
||||
data.put("dungeon_name", after.name());
|
||||
data.put("before_revision", before.revision().toString());
|
||||
data.put("after_revision", after.revision().toString());
|
||||
data.put("before_configured", before.rewardConfigured());
|
||||
data.put("before_xp", before.completionReward().xp().toString());
|
||||
data.put("before_coins", before.completionReward().coins().toString());
|
||||
data.put("after_xp", after.completionReward().xp().toString());
|
||||
data.put("after_coins", after.completionReward().coins().toString());
|
||||
data.put("application_scope", "future_acceptances");
|
||||
outbox.durable("dungeon.reward_changed", operation,
|
||||
actor == null ? new FishingEventBridge.Actor("system", null) : actor, data);
|
||||
}
|
||||
|
||||
/** Only an authoritative defeated run carrying captured native boss proof can attest closure. */
|
||||
public void completed(DungeonState.Run run) {
|
||||
if (!run.defeated() || run.completionPublished() || run.completionAudit().isEmpty()) return; // Older runs have no defensible proof.
|
||||
var audit = run.completionAudit();
|
||||
var operation = completionOperation(run);
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("dungeon_id", run.dungeon().toString());
|
||||
data.put("dungeon_name", required(audit, "dungeon_name"));
|
||||
data.put("room_id", UUID.fromString(required(audit, "room_id")).toString());
|
||||
data.put("generation_id", run.generation().toString());
|
||||
data.put("boss_id", UUID.fromString(required(audit, "boss_id")).toString());
|
||||
data.put("completed_at", required(audit, "defeated_at"));
|
||||
if (audit.containsKey("defeat_world_id")) {
|
||||
var location = new LinkedHashMap<String, Object>();
|
||||
location.put("world_id", UUID.fromString(required(audit, "defeat_world_id")).toString());
|
||||
location.put("world_name", required(audit, "defeat_world_name"));
|
||||
for (var coordinate : java.util.List.of("x", "y", "z"))
|
||||
location.put(coordinate, Double.parseDouble(required(audit, "defeat_" + coordinate)));
|
||||
data.put("death_location", location);
|
||||
} else data.put("death_location", Map.of("status", "unavailable"));
|
||||
int count = Integer.parseInt(required(audit, "represented_count"));
|
||||
if (count < 0 || count > 10_000) throw new IllegalArgumentException("Invalid represented unit count");
|
||||
var represented = new java.util.ArrayList<Map<String, Object>>(count);
|
||||
var receipts = new java.util.ArrayList<String>(count);
|
||||
for (int index = 0; index < count; index++) {
|
||||
var prefix = "represented_" + index + "_";
|
||||
var unit = new LinkedHashMap<String, Object>();
|
||||
unit.put("quest_id", UUID.fromString(required(audit, prefix + "quest_id")).toString());
|
||||
unit.put("unit_kind", required(audit, prefix + "unit_kind"));
|
||||
unit.put("unit_id", UUID.fromString(required(audit, prefix + "unit_id")).toString());
|
||||
var roster = required(audit, prefix + "accepted_roster");
|
||||
unit.put("accepted_roster", roster.isEmpty() ? java.util.List.of() :
|
||||
java.util.Arrays.stream(roster.split(",")).map(UUID::fromString).map(UUID::toString).toList());
|
||||
var receipt = UUID.fromString(required(audit, prefix + "receipt_id")).toString();
|
||||
unit.put("receipt_id", receipt);
|
||||
receipts.add(receipt);
|
||||
represented.add(unit);
|
||||
}
|
||||
data.put("represented_units", represented);
|
||||
data.put("receipt_ids", receipts);
|
||||
data.put("cleanup_status", "completed");
|
||||
outbox.durable("dungeon.completed", operation,
|
||||
new FishingEventBridge.Actor("system", null), data);
|
||||
}
|
||||
|
||||
public static UUID completionOperation(DungeonState.Run run) {
|
||||
return UUID.nameUUIDFromBytes(("dungeon:completed:" + run.dungeon() + ":" + run.generation())
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public static UUID completionEventId(DungeonState.Run run) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:fishing:" + completionOperation(run)
|
||||
+ ":dungeon.completed").getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** Read only exported UTC-day segments, never an uncommitted pending intent. Call off the tick thread. */
|
||||
public static boolean exported(java.nio.file.Path data, UUID eventId, UUID dungeon,
|
||||
UUID generation, UUID boss) throws java.io.IOException {
|
||||
var events = data.toAbsolutePath().resolve("events");
|
||||
if (java.nio.file.Files.notExists(events)) return false;
|
||||
try (var files = java.nio.file.Files.walk(events)) {
|
||||
for (var path : files.filter(file -> java.nio.file.Files.isRegularFile(file,
|
||||
java.nio.file.LinkOption.NOFOLLOW_LINKS)
|
||||
&& file.getFileName().toString().endsWith(".jsonl")).sorted().toList()) {
|
||||
for (var line : java.nio.file.Files.readAllLines(path,java.nio.charset.StandardCharsets.UTF_8)) {
|
||||
if (line.isBlank()) continue;
|
||||
final com.google.gson.JsonObject record;
|
||||
try { record = com.google.gson.JsonParser.parseString(line).getAsJsonObject(); }
|
||||
catch (RuntimeException corrupt) { throw new java.io.IOException("Invalid exported event segment",corrupt); }
|
||||
if (!eventId.toString().equals(record.get("id").getAsString())) continue;
|
||||
var details = record.getAsJsonObject("data");
|
||||
if (record.get("type").getAsString().endsWith("dungeon.completed")
|
||||
&& dungeon.toString().equals(details.get("dungeon_id").getAsString())
|
||||
&& generation.toString().equals(details.get("generation_id").getAsString())
|
||||
&& boss.toString().equals(details.get("boss_id").getAsString())) return true;
|
||||
throw new java.io.IOException("Conflicting exported dungeon completion identity");
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException invalid) { throw new java.io.IOException("Invalid exported dungeon completion proof",invalid); }
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String required(Map<String, String> audit, String key) {
|
||||
var value = audit.get(key);
|
||||
if (value == null) throw new IllegalArgumentException("Missing committed dungeon completion " + key);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
|
||||
new Thread(task, "fantasy-feature-event-intents"));
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
private final Set<UUID> inFlight = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
|
||||
FeatureEventOutbox(Path data, String folder, Set<String> types,
|
||||
Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
@@ -239,12 +240,18 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void publish(EventInput event) {
|
||||
if (!inFlight.add(event.id())) return;
|
||||
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); }
|
||||
try {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (delivery == LocalEventLog.Delivery.REJECTED) return;
|
||||
try { if (Files.deleteIfExists(path(event))) forceDirectory(); }
|
||||
catch (IOException error) { report(error); }
|
||||
} finally { inFlight.remove(event.id()); }
|
||||
}); } catch (RuntimeException failure) {
|
||||
inFlight.remove(event.id());
|
||||
report(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void forceDirectory() throws IOException {
|
||||
|
||||
@@ -26,7 +26,8 @@ public final class FishingEventBridge implements AutoCloseable {
|
||||
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");
|
||||
"adventure.trophy_earned", "adventure.trophy_redeemed", "combat.reward_resolved",
|
||||
"dungeon.reward_changed", "dungeon.completed");
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final EquipmentSessions sessions;
|
||||
@@ -130,7 +131,8 @@ public final class FishingEventBridge implements AutoCloseable {
|
||||
/** 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.type() != FishingState.TrophyType.CAMP
|
||||
&& receipt.type() != FishingState.TrophyType.DUNGEON)
|
||||
|| receipt.phase() != FishingState.TrophyPhase.EARNED) return;
|
||||
var audit = receipt.audit();
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
@@ -148,11 +150,16 @@ public final class FishingEventBridge implements AutoCloseable {
|
||||
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 {
|
||||
} else if (receipt.type() == FishingState.TrophyType.CAMP) {
|
||||
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));
|
||||
} else {
|
||||
evidence.put("boss_id", audit.getOrDefault("boss_id", "unavailable_legacy_capture"));
|
||||
evidence.put("room_id", audit.getOrDefault("room_id", "unavailable_legacy_capture"));
|
||||
evidence.put("boss_death_location", savedDefeatLocation(audit));
|
||||
evidence.put("completed_at", audit.getOrDefault("defeated_at", "unavailable_legacy_capture"));
|
||||
}
|
||||
evidence.put("unit_kind", audit.getOrDefault("unit_kind", "unavailable_legacy_capture"));
|
||||
evidence.put("unit_id", audit.getOrDefault("unit_id", "unavailable_legacy_capture"));
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.HardMobXpSettings;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
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.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Captured Witch XP policy history; replay never configures combat rewards. */
|
||||
public final class HardMobXpEventBridge implements AutoCloseable {
|
||||
public record Actor(String kind, UUID playerId, String playerName, EventInput.Context context) {}
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock = Clock.systemUTC();
|
||||
|
||||
public HardMobXpEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Path data, Consumer<Throwable> failures) {
|
||||
outbox = new FeatureEventOutbox(data, "hard-mob-xp-event-intents", Set.of("config.read", "config.changed"), sink, failures);
|
||||
}
|
||||
|
||||
public Actor actor(CommandSender sender, EquipmentSessions sessions) {
|
||||
String kind = sender instanceof Player ? "player" : sender instanceof org.bukkit.command.ConsoleCommandSender
|
||||
? "console" : sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "system";
|
||||
if (!(sender instanceof Player player)) return new Actor(kind, null, null, null);
|
||||
UUID id = null; String name = null; EventInput.Context context = null;
|
||||
try { id = player.getUniqueId(); } catch (RuntimeException ignored) { }
|
||||
try { name = player.getName(); } catch (RuntimeException ignored) { }
|
||||
try {
|
||||
var active = id == null ? null : sessions.active(id).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
var at = player.getLocation();
|
||||
var location = at == null || at.getWorld() == null ? null : new EventInput.Location(
|
||||
at.getWorld().getUID(), at.getWorld().getName(), at.getX(), at.getY(), at.getZ(), clock.instant(), "native");
|
||||
context = new EventInput.Context(id, name, record == null ? null : record.id(),
|
||||
record == null ? null : record.name(), active == null ? null : active.session(), location);
|
||||
} catch (RuntimeException ignored) { }
|
||||
return new Actor(kind, id, name, context);
|
||||
}
|
||||
|
||||
public void read(Actor actor, String value) {
|
||||
record("config.read", actor, UUID.randomUUID(), null, value);
|
||||
}
|
||||
|
||||
public void changed(Actor actor, UUID operation, String before, String value) {
|
||||
record("config.changed", actor, operation, before, value);
|
||||
}
|
||||
|
||||
private void record(String type, Actor actor, UUID operation, String before, String value) {
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", actor == null ? "unavailable" : actor.kind());
|
||||
data.put("request_actor_kind", actor == null ? "unavailable" : actor.kind());
|
||||
data.put("actor_capture_quality", actor == null || "player".equals(actor.kind()) && actor.context() == null ? "unavailable" : "native");
|
||||
if (actor != null && actor.playerId() != null) data.put("actor_player_id", actor.playerId().toString());
|
||||
if (actor != null && actor.playerName() != null) data.put("actor_player_name", actor.playerName());
|
||||
data.put("key", HardMobXpSettings.KEY); data.put("after", value); data.put("effective", value);
|
||||
if (before != null) data.put("before", before);
|
||||
data.put("application_scope", "future_uncaptured_defeats"); data.put("outcome", "acknowledged");
|
||||
outbox.accepted(new EventInput(operation, operation, clock.instant(), type,
|
||||
actor == null ? null : actor.context(), Map.copyOf(data)));
|
||||
} catch (RuntimeException ignored) { /* Policy publication and reads remain independent of auditing. */ }
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.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.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
|
||||
/** Audit-only final-blow snapshots; replaying a heal intent never invokes gameplay. */
|
||||
public final class HealthStealEventBridge implements AutoCloseable {
|
||||
public record Damage(UUID id, Instant time, UUID target, String targetType,
|
||||
UUID directEntity, String cause, String amount, EventInput.Location location) { }
|
||||
public record Action(UUID id, Damage damage, EventInput.Context context, EventInput.Location targetLocation,
|
||||
UUID targetCharacter, UUID targetSession, BigInteger configured, BigDecimal requested,
|
||||
CharacterRecord before, BigDecimal healthBefore, BigDecimal maximum) { }
|
||||
|
||||
private final Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
private final FeatureEventOutbox outbox;
|
||||
|
||||
public HealthStealEventBridge(Consumer<EventInput> sink, Clock clock) {
|
||||
this.sink = Objects.requireNonNull(sink); this.clock = Objects.requireNonNull(clock); outbox = null;
|
||||
}
|
||||
public HealthStealEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, Consumer<Throwable> failures) {
|
||||
this.sink = event -> sink.apply(event); this.clock = Objects.requireNonNull(clock);
|
||||
outbox = new FeatureEventOutbox(data, "health-steal-event-intents", java.util.Set.of("combat.healed"), sink, failures);
|
||||
}
|
||||
|
||||
/** Called only for an already accepted lethal candidate, before its native death callback. */
|
||||
public Damage damage(EntityDamageEvent event) {
|
||||
try {
|
||||
var target = event.getEntity(); var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct == null && event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|
||||
direct = byEntity.getDamager();
|
||||
return new Damage(UUID.randomUUID(), clock.instant(), target.getUniqueId(),
|
||||
target instanceof Player ? "PLAYER" : target.getType().name(), direct == null ? null : direct.getUniqueId(),
|
||||
event.getCause().name(), Double.toString(event.getFinalDamage()), location(target.getLocation()));
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public Action begin(Damage damage, LivingEntity target, Player player, EquipmentSessions.Active active,
|
||||
CharacterRecord before, EquipmentSessions.Active targetActive,
|
||||
BigInteger configured, double requested, double current, double maximum) {
|
||||
if (damage == null) return null;
|
||||
try {
|
||||
var context = new EventInput.Context(player.getUniqueId(), player.getName(), active.character(),
|
||||
before.name(), active.session(), location(player.getLocation()));
|
||||
var action = new Action(id(damage.id(), "defeat"), damage, context, location(target.getLocation()),
|
||||
targetActive == null ? null : targetActive.character(), targetActive == null ? null : targetActive.session(),
|
||||
configured, BigDecimal.valueOf(requested), before, BigDecimal.valueOf(current), BigDecimal.valueOf(maximum));
|
||||
emit("action.observed", action, "observed", null, before, null, action.healthBefore(), null, action.maximum(), null);
|
||||
return action;
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
public void refused(Action action, String reason) {
|
||||
if (action != null) emit("action.resolved", action, "refused", reason, action.before(), null,
|
||||
action.healthBefore(), null, action.maximum(), null);
|
||||
}
|
||||
public void failed(Action action, String reason) {
|
||||
if (action != null) emit("action.resolved", action, "failed", reason, action.before(), null,
|
||||
action.healthBefore(), null, action.maximum(), null);
|
||||
}
|
||||
public void healed(Action action, CharacterRecord before, CharacterRecord after,
|
||||
double nativeBefore, double nativeAfter, double maximum, double accepted) {
|
||||
if (action == null) return;
|
||||
var prior = BigDecimal.valueOf(nativeBefore); var next = BigDecimal.valueOf(nativeAfter);
|
||||
var limit = BigDecimal.valueOf(maximum); var amount = BigDecimal.valueOf(accepted);
|
||||
emit("action.resolved", action, "accepted", "native_health_acknowledged", before, after, prior, next, limit, amount);
|
||||
emit("combat.healed", action, "accepted", "native_health_acknowledged", before, after, prior, next, limit, amount);
|
||||
}
|
||||
|
||||
private void emit(String type, Action action, String outcome, String reason, CharacterRecord before,
|
||||
CharacterRecord after, BigDecimal prior, BigDecimal next, BigDecimal maximum, BigDecimal accepted) {
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>(); var damage = action.damage();
|
||||
data.put("action_kind", "health_steal_final_blow"); data.put("passive_id", "health_steal");
|
||||
data.put("healing_source", "health_steal"); data.put("defeat_id", action.id().toString());
|
||||
data.put("damage_causation_id", damage.id().toString()); data.put("damage_accepted_at", damage.time().toString());
|
||||
data.put("damage_cause", damage.cause()); data.put("damage_amount", damage.amount());
|
||||
data.put("target_id", damage.target().toString()); data.put("target_type", damage.targetType());
|
||||
if (damage.directEntity() != null) data.put("direct_entity_id", damage.directEntity().toString());
|
||||
if (damage.location() != null) data.put("damage_target_location", values(damage.location()));
|
||||
if (action.targetLocation() != null) data.put("target_location", values(action.targetLocation()));
|
||||
data.put("target_location_capture_quality", action.targetLocation() == null ? "unavailable" : "exact");
|
||||
data.put("location_capture_quality", action.context().location() == null ? "unavailable" : "exact");
|
||||
if (action.targetCharacter() != null) {
|
||||
data.put("target_character_id", action.targetCharacter().toString());
|
||||
data.put("target_session_id", action.targetSession().toString());
|
||||
}
|
||||
data.put("defeat_consumption", "consumed_once"); data.put("configured_amount", action.configured().toString());
|
||||
data.put("requested_amount", action.requested().toPlainString()); data.put("health_before", prior.toPlainString());
|
||||
data.put("maximum_health", maximum.toPlainString()); data.put("before_revision", before.revision().toString());
|
||||
data.put("points_before", before.unspentPoints().toString()); data.put("outcome", outcome);
|
||||
if (reason != null) data.put("reason", reason);
|
||||
if (after != null && next != null) {
|
||||
data.put("health_after", next.toPlainString()); data.put("final_amount", next.subtract(prior).toPlainString());
|
||||
data.put("native_accepted_amount", accepted.toPlainString()); data.put("persisted_health", after.health().toPlainString());
|
||||
data.put("after_revision", after.revision().toString()); data.put("points_after", after.unspentPoints().toString());
|
||||
data.put("native_publication", "acknowledged");
|
||||
}
|
||||
var event = new EventInput(id(action.id(), type), action.id(), clock.instant(), type, action.context(), Map.copyOf(data));
|
||||
if (type.equals("combat.healed") && outbox != null) outbox.accepted(event); else sink.accept(event);
|
||||
} catch (RuntimeException ignored) { /* Audit failure cannot authorize another defeat, save or native heal. */ }
|
||||
}
|
||||
|
||||
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 static Map<String, Object> values(EventInput.Location location) {
|
||||
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", location.quality());
|
||||
}
|
||||
private static UUID id(UUID operation, String type) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:health-steal:" + operation + ":" + 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(); }
|
||||
}
|
||||
@@ -17,21 +17,37 @@ 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);}
|
||||
PassiveDefinition definition,CharacterRecord before,Map<String,Object> actorMetadata){
|
||||
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(passive);Objects.requireNonNull(definition);Objects.requireNonNull(before);actorMetadata=Map.copyOf(actorMetadata);if(context==null&&!games.dmg.fantasystick.passive.StickKnockback.ID.equals(passive))throw new NullPointerException("Passive actor context");}
|
||||
public Action(UUID actionId,EventInput.Context context,String passive,boolean learn,PassiveDefinition definition,CharacterRecord before){this(actionId,context,passive,learn,definition,before,Map.of());}
|
||||
}
|
||||
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 Action begin(ControlInput input,CharacterRecord before,PassiveDefinition definition,boolean learn){var action=captureAction(input.player(),input.session(),before,definition,learn);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=captureAction(player,active,before,definition,learn);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 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);var statValues=new LinkedHashMap<String,Object>();action.definition().requirements().keySet().stream().sorted().forEach(stat->{var value=before.stats().get(stat);if(value!=null)statValues.put(stat.name(),value.toString());});data.put("stat_values_before",statValues);data.put("passive_slots_before",before.passives());data.put("passive_prerequisites",action.definition().prerequisites().stream().sorted().toList());data.put("outcome",outcome);data.putAll(action.actorMetadata());if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());data.put("passive_slots_after",after.passives());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){}
|
||||
public void configRead(String actorKind,UUID player,String playerName,String key,String value,String application){config("config.read",actorKind,player,playerName,key,null,value,application);}
|
||||
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after,String application){config("config.changed",actorKind,player,playerName,key,before,after,application);}
|
||||
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){config(type,actorKind,player,playerName,key,before,after,"future_recovery_pulses");}
|
||||
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after,String application){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",application);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 Action captureAction(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,PassiveDefinition definition,boolean learn){
|
||||
if(!games.dmg.fantasystick.passive.StickKnockback.ID.equals(definition.id()))return new Action(UUID.randomUUID(),context(player,active,before.name()),definition.id(),learn,definition,before);
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_player_id",active.player().toString());data.put("actor_character_id",before.id().toString());data.put("actor_session_id",active.session().toString());data.put("session_id",active.session().toString());
|
||||
String playerName=null;EventInput.Location captured=null;
|
||||
try{playerName=player.getName();}catch(RuntimeException ignored){}
|
||||
data.put("actor_name_capture_quality",playerName==null?"unavailable":"exact");if(playerName!=null)data.put("actor_player_name",playerName);
|
||||
try{captured=location(player.getLocation());}catch(RuntimeException ignored){}
|
||||
data.put("location_capture_quality",captured==null?"unavailable":"exact");
|
||||
if(captured!=null)data.put("actor_location",Map.of("world_id",captured.worldId().toString(),"world_name",captured.worldName(),"x",captured.x(),"y",captured.y(),"z",captured.z(),"captured_at",captured.capturedAt().toString()));
|
||||
var context=playerName==null?null:new EventInput.Context(active.player(),playerName,before.id(),before.name(),active.session(),captured);
|
||||
return new Action(UUID.randomUUID(),context,definition.id(),learn,definition,before,data);
|
||||
}
|
||||
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");}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
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.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
|
||||
/** Records real owned potion layers, not periodic evaluation or a desired passive state. */
|
||||
public final class PassivePotionEventBridge implements AutoCloseable {
|
||||
public record Application(UUID id, String passive, EventInput.Context context, Instant startedAt,
|
||||
String effectType, int amplifier) { }
|
||||
private final Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
private final FeatureEventOutbox outbox;
|
||||
|
||||
public PassivePotionEventBridge(Consumer<EventInput> sink, Clock clock) {
|
||||
this.sink = Objects.requireNonNull(sink); this.clock = Objects.requireNonNull(clock); outbox = null;
|
||||
}
|
||||
public PassivePotionEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, Consumer<Throwable> failures) {
|
||||
this.sink = event -> sink.apply(event); this.clock = Objects.requireNonNull(clock);
|
||||
outbox = new FeatureEventOutbox(data, "passive-potion-event-intents",
|
||||
java.util.Set.of("effect.applied", "effect.ended"), sink, failures);
|
||||
}
|
||||
|
||||
public Application applied(String passive, Player player, EquipmentSessions.Active active,
|
||||
String characterName, PotionEffect owned, PotionEffect previous, String reason) {
|
||||
try {
|
||||
var context = new EventInput.Context(player.getUniqueId(), player.getName(), active.character(),
|
||||
characterName, active.session(), location(player));
|
||||
var application = new Application(UUID.randomUUID(), passive, context, clock.instant(),
|
||||
owned.getType().getKey().toString(), owned.getAmplifier());
|
||||
emit("effect.applied", application, context, reason,
|
||||
previous == null ? "no_unrelated_layer_displaced" : "weaker_native_layer_retained", previous, false);
|
||||
return application;
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
public void ended(Application application, Player player, String reason, String disposition,
|
||||
PotionEffect current, boolean restored) {
|
||||
if (application == null) return;
|
||||
try {
|
||||
var before = application.context();
|
||||
var context = new EventInput.Context(before.playerId(), before.playerName(), before.characterId(),
|
||||
before.characterName(), before.sessionId(), location(player));
|
||||
emit("effect.ended", application, context, reason, disposition, current, restored);
|
||||
} catch (RuntimeException ignored) { /* Audit failure cannot retry or veto native cleanup. */ }
|
||||
}
|
||||
private void emit(String type, Application application, EventInput.Context context, String reason,
|
||||
String disposition, PotionEffect nativeEffect, boolean restored) {
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("passive_id", application.passive()); data.put("effect_id", application.id().toString());
|
||||
data.put("application_id", application.id().toString());
|
||||
data.put("owner_character_id", context.characterId().toString()); data.put("owner_session_id", context.sessionId().toString());
|
||||
data.put("target_player_id", context.playerId().toString()); data.put("effect_type", application.effectType());
|
||||
data.put("amplifier", application.amplifier()); data.put("started_at", application.startedAt().toString());
|
||||
data.put("reason", reason); data.put("native_disposition", disposition); data.put("unrelated_effect_restored", restored);
|
||||
data.put("native_publication", "acknowledged");
|
||||
data.put("location_capture_quality", context.location() == null ? "unavailable" : "exact");
|
||||
if (nativeEffect != null) data.put("unrelated_native_effect", Map.of(
|
||||
"effect_type", nativeEffect.getType().getKey().toString(), "amplifier", nativeEffect.getAmplifier(),
|
||||
"duration_ticks", nativeEffect.getDuration(), "infinite", nativeEffect.isInfinite()));
|
||||
var event = new EventInput(id(application.id(), type), application.id(), clock.instant(), type, context, Map.copyOf(data));
|
||||
if (outbox != null) outbox.accepted(event); else sink.accept(event);
|
||||
} catch (RuntimeException ignored) { /* No native effect operation is reachable from the event exporter. */ }
|
||||
}
|
||||
private EventInput.Location location(Player player) {
|
||||
try {
|
||||
var 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 application, String type) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:passive-potion:" + application + ":" + 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(); }
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public final class PreferenceEventBridge {
|
||||
private final EquipmentSessions sessions;
|
||||
private final PlayerFeedbackPreferences preferences;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final java.util.Set<UUID> inFlight = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
|
||||
public PreferenceEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
|
||||
EquipmentSessions sessions, PlayerFeedbackPreferences preferences,
|
||||
@@ -184,15 +185,32 @@ public final class PreferenceEventBridge {
|
||||
data.put("after", Boolean.toString(action.requested()));
|
||||
data.put("preference_revision", Long.toString(preferenceSequence));
|
||||
}
|
||||
if (acknowledgedIntent) {
|
||||
if (!inFlight.add(action.id())) return;
|
||||
if (preferences.pendingEvents().stream().noneMatch(intent -> intent.actionId().equals(action.id()))) {
|
||||
inFlight.remove(action.id());
|
||||
return; // A stale snapshot cannot replay an already acknowledged preference.
|
||||
}
|
||||
}
|
||||
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());
|
||||
if (failure != null || delivery == LocalEventLog.Delivery.REJECTED) {
|
||||
inFlight.remove(action.id()); // Rejected evidence remains available for a real retry.
|
||||
return;
|
||||
}
|
||||
preferences.acknowledgeEvent(action.id()).whenComplete((ignored, acknowledgementFailure) -> {
|
||||
if (acknowledgementFailure == null) inFlight.remove(action.id());
|
||||
else try { failures.accept(acknowledgementFailure); } catch (RuntimeException ignoredFailure) { }
|
||||
// Accepted delivery must not be exported twice while its durable acknowledgement is uncertain.
|
||||
});
|
||||
});
|
||||
} catch (RuntimeException ignored) { /* Logging cannot affect the command or persistent state. */ }
|
||||
} catch (RuntimeException ignored) {
|
||||
if (acknowledgedIntent) inFlight.remove(action.id());
|
||||
/* Logging cannot affect the command or persistent state. */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,180 @@ package games.dmg.fantasystick.events;
|
||||
import games.dmg.fantasystick.shop.RareCatalog;
|
||||
import games.dmg.fantasystick.shop.RareProduct;
|
||||
import java.math.BigInteger;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Function;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Failure-isolated Phoenix Down catalog and acknowledged purchase event adapter. */
|
||||
public final class RareEventBridge {
|
||||
private final Consumer<EventInput> sink;private final Clock clock;
|
||||
public RareEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
|
||||
public void catalogRefreshed(RareCatalog catalog){if(!catalog.available(RareProduct.PHOENIX_DOWN))return;var offer=catalog.offer(RareProduct.PHOENIX_DOWN);var data=new LinkedHashMap<String,Object>();data.put("actor_kind","system");data.put("product_id",RareProduct.PHOENIX_DOWN.id());data.put("catalog_revision",catalog.revision().toString());data.put("catalog_day",catalog.day().toString());data.put("refresh_reason",catalog.reason().name().toLowerCase(java.util.Locale.ROOT));data.put("price",offer.price().toString());data.put("stock",Integer.toString(offer.remaining()));data.put("outcome","acknowledged");emit(stable(catalog.revision(),"phoenix-catalog"),catalog.revision(),catalog.refreshedAt(),"rare_catalog.refreshed",null,data);}
|
||||
public void purchased(UUID operation,UUID catalog,UUID player,String playerName,UUID character,String characterName,UUID session,BigInteger price,int stockBefore,int stockAfter,BigInteger quantityBefore,BigInteger quantityAfter){var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id",RareProduct.PHOENIX_DOWN.id());data.put("catalog_revision",catalog.toString());data.put("purchase_operation_id",operation.toString());data.put("price",price.toString());data.put("stock_before",Integer.toString(stockBefore));data.put("stock_after",Integer.toString(stockAfter));data.put("quantity_before",quantityBefore.toString());data.put("quantity_after",quantityAfter.toString());data.put("outcome","acknowledged");var context=new EventInput.Context(player,playerName,character,characterName,session,null);emit(stable(operation,"phoenix-purchased"),operation,clock.instant(),"rare_item.purchased",context,data);}
|
||||
private void emit(UUID id,UUID action,java.time.Instant time,String type,EventInput.Context context,java.util.Map<String,Object> data){try{sink.accept(new EventInput(id,action,time,type,context,data));}catch(RuntimeException ignored){}}
|
||||
/** Failure-isolated rare catalog and acknowledged purchase event adapter. */
|
||||
public final class RareEventBridge implements AutoCloseable {
|
||||
private final Consumer<EventInput> sink;private final Clock clock;private final FeatureEventOutbox outbox;
|
||||
private final java.util.Set<UUID> catalogEvents=java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
public RareEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);this.outbox=null;}
|
||||
public RareEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,Clock clock,Path data,Consumer<Throwable> failures){
|
||||
this.sink=null;this.clock=Objects.requireNonNull(clock);
|
||||
this.outbox=new FeatureEventOutbox(data,"rare-event-intents",Set.of("rare_catalog.refreshed",
|
||||
"rare_shop.placed","rare_shop.removed","rare_item.purchased","rare_item.used",
|
||||
"config.read","config.changed","action.observed","action.resolved"),sink,failures);
|
||||
}
|
||||
public void replay(){if(outbox!=null)outbox.replay();}
|
||||
@Override public void close(){if(outbox!=null)outbox.close();}
|
||||
public CompletionStage<Void> drained(){return outbox==null?CompletableFuture.completedFuture(null):outbox.drained();}
|
||||
public void catalogRefreshed(RareCatalog catalog){
|
||||
if(!catalogEvents.add(catalog.revision()))return;
|
||||
var products=new java.util.ArrayList<java.util.Map<String,String>>();
|
||||
for(var product:RareProduct.values())if(catalog.available(product)){
|
||||
var offer=catalog.offer(product);
|
||||
products.add(java.util.Map.of("product_id",product.id(),"price",offer.price().toString(),
|
||||
"stock",Integer.toString(offer.remaining())));
|
||||
}
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","system");
|
||||
data.put("catalog_revision",catalog.revision().toString());data.put("catalog_day",catalog.day().toString());
|
||||
data.put("refresh_reason",catalog.reason().name().toLowerCase(java.util.Locale.ROOT));
|
||||
data.put("next_midnight",catalog.day().plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant().toString());
|
||||
data.put("products",products);data.put("outcome","acknowledged");
|
||||
emit(stable(catalog.revision(),"rare-catalog"),catalog.revision(),catalog.refreshedAt(),
|
||||
"rare_catalog.refreshed",null,data);
|
||||
}
|
||||
public void purchased(UUID operation,UUID catalog,UUID player,String playerName,UUID character,String characterName,UUID session,BigInteger price,int stockBefore,int stockAfter,BigInteger quantityBefore,BigInteger quantityAfter){
|
||||
purchased(RareProduct.PHOENIX_DOWN,operation,catalog,null,player,playerName,character,characterName,
|
||||
session,price,stockBefore,stockAfter,quantityBefore,quantityAfter,null,null,null,null);
|
||||
}
|
||||
public void purchased(RareProduct product,UUID operation,UUID catalog,UUID shop,UUID player,String playerName,
|
||||
UUID character,String characterName,UUID session,BigInteger price,int stockBefore,int stockAfter,
|
||||
BigInteger quantityBefore,BigInteger quantityAfter,BigInteger coinsBefore,BigInteger coinsAfter,
|
||||
BigInteger revisionBefore,BigInteger revisionAfter){
|
||||
purchased(product,operation,catalog,shop,player,playerName,character,characterName,session,price,
|
||||
stockBefore,stockAfter,quantityBefore,quantityAfter,coinsBefore,coinsAfter,revisionBefore,revisionAfter,null);
|
||||
}
|
||||
public void purchased(RareProduct product,UUID operation,UUID catalog,UUID shop,UUID player,String playerName,
|
||||
UUID character,String characterName,UUID session,BigInteger price,int stockBefore,int stockAfter,
|
||||
BigInteger quantityBefore,BigInteger quantityAfter,BigInteger coinsBefore,BigInteger coinsAfter,
|
||||
BigInteger revisionBefore,BigInteger revisionAfter,EventInput.Location location){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id",product.id());
|
||||
data.put("catalog_revision",catalog.toString());data.put("purchase_operation_id",operation.toString());
|
||||
if(shop!=null)data.put("shop_id",shop.toString());
|
||||
data.put("price",price.toString());data.put("stock_before",Integer.toString(stockBefore));
|
||||
data.put("stock_after",Integer.toString(stockAfter));data.put("quantity_before",quantityBefore.toString());
|
||||
data.put("quantity_after",quantityAfter.toString());
|
||||
if(coinsBefore!=null&&coinsAfter!=null){data.put("coins_before",coinsBefore.toString());data.put("coins_after",coinsAfter.toString());}
|
||||
if(revisionBefore!=null&&revisionAfter!=null){data.put("revision_before",revisionBefore.toString());data.put("revision_after",revisionAfter.toString());}
|
||||
data.put("outcome","acknowledged");
|
||||
var context=new EventInput.Context(player,playerName,character,characterName,session,location);
|
||||
emit(stable(operation,product==RareProduct.PHOENIX_DOWN?"phoenix-purchased":"rare-purchased"),operation,
|
||||
clock.instant(),"rare_item.purchased",context,data);
|
||||
}
|
||||
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 config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){
|
||||
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",key.endsWith("-healing-percent")?"future_potion_uses":"next_rare_restock");
|
||||
data.put("outcome","acknowledged");var operation=UUID.randomUUID();
|
||||
var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);
|
||||
emit(stable(operation,type),operation,clock.instant(),type,context,data);
|
||||
}
|
||||
public void shopPlaced(games.dmg.fantasystick.shop.RareMerchant shop,UUID administrator,String name){
|
||||
shopChanged("rare_shop.placed",shop.id(),shop,administrator,name);
|
||||
}
|
||||
public void shopRemoved(UUID operation,games.dmg.fantasystick.shop.RareMerchant shop,UUID administrator,String name){
|
||||
shopChanged("rare_shop.removed",operation,shop,administrator,name);
|
||||
}
|
||||
private void shopChanged(String type,UUID operation,games.dmg.fantasystick.shop.RareMerchant shop,UUID administrator,String name){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_kind",administrator==null?"console":"player");
|
||||
if(name!=null)data.put("administrator_name",name);
|
||||
data.put("shop_id",shop.id().toString());data.put("shop_name",shop.name());
|
||||
data.put("world_id",shop.world().toString());data.put("x",shop.x());data.put("y",shop.y());
|
||||
data.put("z",shop.z());data.put("yaw",(double)shop.yaw());data.put("pitch",(double)shop.pitch());
|
||||
data.put("outcome","acknowledged");
|
||||
var context=administrator==null?null:new EventInput.Context(administrator,name,null,null,null,null);
|
||||
emit(stable(operation,type),operation,clock.instant(),type,context,data);
|
||||
}
|
||||
public EventInput.Context potionContext(org.bukkit.entity.Player player,UUID character,String name,UUID session){
|
||||
var nativeLocation=player.getLocation();var world=nativeLocation.getWorld();
|
||||
var location=world==null?null:new EventInput.Location(world.getUID(),world.getName(),
|
||||
nativeLocation.getX(),nativeLocation.getY(),nativeLocation.getZ(),clock.instant(),"native");
|
||||
return new EventInput.Context(player.getUniqueId(),player.getName(),character,name,session,location);
|
||||
}
|
||||
public void potionUsed(UUID operation,RareProduct product,EventInput.Context context,BigInteger quantityBefore,
|
||||
int percent,BigDecimal healthBefore,BigDecimal healthAfter,BigDecimal maximum,
|
||||
BigDecimal requested,BigDecimal applied,BigInteger revisionBefore,BigInteger revisionAfter){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id",product.id());
|
||||
data.put("use_operation_id",operation.toString());data.put("quantity_before",quantityBefore.toString());
|
||||
data.put("quantity_after",quantityBefore.subtract(BigInteger.ONE).toString());
|
||||
data.put("healing_percent",Integer.toString(percent));data.put("health_before",healthBefore.stripTrailingZeros().toPlainString());
|
||||
data.put("health_after",healthAfter.stripTrailingZeros().toPlainString());
|
||||
data.put("maximum_health",maximum.stripTrailingZeros().toPlainString());
|
||||
data.put("requested_healing",requested.stripTrailingZeros().toPlainString());
|
||||
data.put("applied_healing",applied.stripTrailingZeros().toPlainString());
|
||||
data.put("revision_before",revisionBefore.toString());data.put("revision_after",revisionAfter.toString());
|
||||
data.put("outcome","acknowledged");
|
||||
emit(stable(operation,"potion-used"),operation,clock.instant(),"rare_item.used",context,data);
|
||||
}
|
||||
public void magicCoreUsed(UUID operation,EventInput.Context context,BigInteger quantityBefore,
|
||||
games.dmg.fantasystick.persistence.CharacterRecord before,
|
||||
games.dmg.fantasystick.persistence.CharacterRecord after){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id","magic-core");
|
||||
data.put("use_operation_id",operation.toString());data.put("quantity_before",quantityBefore.toString());
|
||||
data.put("quantity_after",quantityBefore.subtract(BigInteger.ONE).toString());
|
||||
data.put("points_before",before.unspentPoints().toString());data.put("points_after",after.unspentPoints().toString());
|
||||
data.put("credited_points",after.unspentPoints().subtract(before.unspentPoints()).toString());
|
||||
data.put("revision_before",before.revision().toString());data.put("revision_after",after.revision().toString());
|
||||
data.put("level_before",Integer.toString(before.level()));data.put("level_after",Integer.toString(after.level()));
|
||||
data.put("xp_before",before.experience().toString());data.put("xp_after",after.experience().toString());
|
||||
data.put("outcome","acknowledged");
|
||||
emit(stable(operation,"magic-core-used"),operation,clock.instant(),"rare_item.used",context,data);
|
||||
}
|
||||
public void reskillAction(UUID operation,EventInput.Context context,String phase,String outcome,String reason){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id","reskill");
|
||||
data.put("action_kind","reskill_use");data.put("use_operation_id",operation.toString());
|
||||
data.put("phase",phase);data.put("outcome",outcome);data.put("reason",reason);
|
||||
String type=outcome.equals("observed")?"action.observed":"action.resolved";
|
||||
emit(stable(operation,"reskill-"+phase+"-"+type),operation,clock.instant(),type,context,data);
|
||||
}
|
||||
public void reskillUsed(UUID operation,EventInput.Context context,BigInteger quantityBefore,
|
||||
games.dmg.fantasystick.persistence.CharacterRecord before,
|
||||
games.dmg.fantasystick.persistence.CharacterRecord after,
|
||||
BigDecimal healthBefore,BigDecimal healthAfter,BigDecimal maximumBefore,BigDecimal maximumAfter){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id","reskill");
|
||||
data.put("use_operation_id",operation.toString());data.put("quantity_before",quantityBefore.toString());
|
||||
data.put("quantity_after",quantityBefore.subtract(BigInteger.ONE).toString());
|
||||
data.put("revision_before",before.revision().toString());data.put("revision_after",after.revision().toString());
|
||||
var statsBefore=new LinkedHashMap<String,String>();var statsAfter=new LinkedHashMap<String,String>();
|
||||
for(var stat:games.dmg.fantasystick.progression.Stat.values()){
|
||||
String key=stat.name().toLowerCase(java.util.Locale.ROOT);
|
||||
statsBefore.put(key,before.stats().get(stat).toString());statsAfter.put(key,after.stats().get(stat).toString());
|
||||
}
|
||||
data.put("stats_before",statsBefore);data.put("stats_after",statsAfter);
|
||||
data.put("removed_learned",before.learned().stream().sorted().toList());
|
||||
data.put("removed_passives",before.passives().stream().filter(value->!value.isEmpty()).distinct().sorted().toList());
|
||||
data.put("active_slots_before",before.loadout());data.put("active_slots_after",after.loadout());
|
||||
data.put("passive_slots_before",before.passives());data.put("passive_slots_after",after.passives());
|
||||
data.put("points_before",before.unspentPoints().toString());data.put("points_after",after.unspentPoints().toString());
|
||||
data.put("refunded_points",after.unspentPoints().subtract(before.unspentPoints()).toString());
|
||||
data.put("health_before",healthBefore.stripTrailingZeros().toPlainString());
|
||||
data.put("health_after",healthAfter.stripTrailingZeros().toPlainString());
|
||||
data.put("maximum_before",maximumBefore.stripTrailingZeros().toPlainString());
|
||||
data.put("maximum_after",maximumAfter.stripTrailingZeros().toPlainString());
|
||||
var deadlines=after.cooldowns().entrySet().stream().sorted(java.util.Map.Entry.comparingByKey())
|
||||
.map(entry->java.util.Map.of("ability_id",entry.getKey(),"deadline",entry.getValue().toString())).toList();
|
||||
data.put("retained_cooldowns",deadlines);data.put("cleanup_outcome","acknowledged");data.put("outcome","acknowledged");
|
||||
emit(stable(operation,"reskill-used"),operation,clock.instant(),"rare_item.used",context,data);
|
||||
}
|
||||
private void emit(UUID id,UUID action,java.time.Instant time,String type,EventInput.Context context,java.util.Map<String,Object> data){try{var event=new EventInput(id,action,time,type,context,data);if(outbox!=null)outbox.accepted(event);else sink.accept(event);}catch(RuntimeException ignored){}}
|
||||
private static UUID stable(UUID authority,String kind){return UUID.nameUUIDFromBytes(("fantasystick:"+authority+':'+kind).getBytes(StandardCharsets.UTF_8));}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.passive.StickKnockback;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Set;
|
||||
import java.time.Instant;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.util.Vector;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Knockback-owned immutable audit intents; replay never touches controls, entities or gameplay. */
|
||||
public final class StickKnockbackEventBridge implements AutoCloseable, Listener {
|
||||
private final FeatureEventOutbox outbox;
|
||||
// Server-thread invocation scopes. The first callbacks belong to this exact native call;
|
||||
// nested external/native calls cannot replace already-bound observations.
|
||||
private final ArrayDeque<Pending> scopes = new ArrayDeque<>();
|
||||
private static final StackWalker NATIVE_STACK = StackWalker.getInstance();
|
||||
private static final class Pending {
|
||||
final UUID action;
|
||||
final UUID target;
|
||||
final EventInput.Context actor;
|
||||
final Map<String, Object> data;
|
||||
long nativeDepth;
|
||||
org.bukkit.event.entity.EntityKnockbackEvent bukkit;
|
||||
io.papermc.paper.event.entity.EntityKnockbackEvent paper;
|
||||
Pending(UUID action, UUID target, EventInput.Context actor, Map<String, Object> data) {
|
||||
this.action = action; this.target = target; this.actor = actor; this.data = Map.copyOf(data);
|
||||
}
|
||||
}
|
||||
|
||||
public StickKnockbackEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Path data, Consumer<Throwable> failures) {
|
||||
outbox = new FeatureEventOutbox(data, "stick-knockback-event-intents",
|
||||
Set.of("passive.learned", "passive.unlearned", "effect.applied"), sink, failures);
|
||||
}
|
||||
|
||||
/** Called only after PassiveEngine has confirmed save and native control publication. */
|
||||
public void acknowledgedPassive(EventInput input) {
|
||||
if (!StickKnockback.ID.equals(input.data().get("passive_id"))
|
||||
|| !(input.type().equals("passive.learned") || input.type().equals("passive.unlearned"))) return;
|
||||
var data = new LinkedHashMap<>(input.data());
|
||||
var before = new BigInteger((String) data.get("points_before"));
|
||||
var after = new BigInteger((String) data.get("points_after"));
|
||||
data.put("point_delta", after.subtract(before).toString());
|
||||
data.put("operation_id", input.actionId().toString());
|
||||
if (input.context() != null) data.put("session_id", input.context().sessionId().toString());
|
||||
data.put("control_refresh_result", "acknowledged");
|
||||
data.put("installed_knockback_tier", input.type().equals("passive.learned") ? 1 : 0);
|
||||
outbox.accepted(new EventInput(input.id(), input.actionId(), input.time(), input.type(), input.context(), data));
|
||||
}
|
||||
|
||||
/** Observe, never decide, the existing native passive application after accepted Basic Attack. */
|
||||
public void apply(BasicAttackEventBridge.Action action, ControlInput input, CharacterRecord record,
|
||||
LivingEntity target, EquipmentSessions sessions) {
|
||||
Pending pending = null;
|
||||
try { pending = capture(action, input, record, target, sessions); }
|
||||
catch (RuntimeException ignored) { /* Audit capture has no gameplay authority. */ }
|
||||
if (pending != null) scopes.push(pending);
|
||||
boolean returned = false;
|
||||
try { StickKnockback.apply(input.player(), target); returned = true; }
|
||||
finally {
|
||||
if (pending != null) {
|
||||
scopes.removeFirstOccurrence(pending);
|
||||
if (returned) {
|
||||
try { applied(pending, target); }
|
||||
catch (RuntimeException ignored) { /* Do not undo or interrupt accepted native gameplay. */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
|
||||
public void observedBukkit(org.bukkit.event.entity.EntityKnockbackEvent event) {
|
||||
var pending = scopes.peek();
|
||||
try {
|
||||
if (pending != null && pending.bukkit == null && pending.target.equals(event.getEntity().getUniqueId())
|
||||
&& event.getCause() == org.bukkit.event.entity.EntityKnockbackEvent.KnockbackCause.UNKNOWN) {
|
||||
pending.nativeDepth = nativeDepth(); pending.bukkit = event;
|
||||
}
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
|
||||
public void observedPaper(io.papermc.paper.event.entity.EntityKnockbackEvent event) {
|
||||
var pending = scopes.peek();
|
||||
try {
|
||||
if (pending != null && pending.bukkit != null && pending.paper == null
|
||||
&& pending.target.equals(event.getEntity().getUniqueId())
|
||||
&& event.getCause() == io.papermc.paper.event.entity.EntityKnockbackEvent.Cause.UNKNOWN
|
||||
&& pending.nativeDepth == nativeDepth()) pending.paper = event;
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private static long nativeDepth() {
|
||||
return NATIVE_STACK.walk(frames -> frames.filter(frame -> frame.getMethodName().equals("knockback")).count());
|
||||
}
|
||||
|
||||
private Pending capture(BasicAttackEventBridge.Action action, ControlInput input, CharacterRecord record,
|
||||
LivingEntity target, EquipmentSessions sessions) {
|
||||
var operation = action == null ? UUID.randomUUID() : action.id();
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("passive_id", StickKnockback.ID); data.put("action_kind", "basic_attack_knockback");
|
||||
data.put("operation_id", operation.toString()); data.put("effect_id", id(operation).toString());
|
||||
data.put("source_player_id", input.session().player().toString());
|
||||
data.put("source_character_id", record.id().toString());
|
||||
data.put("source_session_id", input.session().session().toString());
|
||||
data.put("source_revision", record.revision().toString());
|
||||
if (action != null) data.put("damage_event_id", UUID.nameUUIDFromBytes(
|
||||
("fantasystick:basic-attack:" + action.id() + ":damage").getBytes(StandardCharsets.UTF_8)).toString());
|
||||
else data.put("damage_event_id_status", "unavailable_basic_attack_audit");
|
||||
var targetId = target.getUniqueId(); data.put("target_entity_id", targetId.toString());
|
||||
try { data.put("target_entity_type", target.getType().name().toLowerCase(java.util.Locale.ROOT)); }
|
||||
catch (RuntimeException ignored) { data.put("target_entity_type_status", "unavailable"); }
|
||||
if (target instanceof org.bukkit.entity.Player) {
|
||||
data.put("target_player_id", targetId.toString());
|
||||
var active = sessions.active(targetId).filter(sessions::current).orElse(null);
|
||||
if (active != null) {
|
||||
data.put("target_character_id", active.character().toString());
|
||||
data.put("target_session_id", active.session().toString());
|
||||
} else data.put("target_character_status", "unavailable");
|
||||
}
|
||||
boolean sprinting = input.player().isSprinting();
|
||||
data.put("sprinting", sprinting); data.put("strength", sprinting ? 2d : 1d); data.put("knockback_tier", 1);
|
||||
location(data, "source_location", input.player()::getLocation);
|
||||
location(data, "target_location", target::getLocation);
|
||||
velocity(data, "velocity_before", target);
|
||||
try {
|
||||
var attribute = target.getAttribute(org.bukkit.attribute.Attribute.KNOCKBACK_RESISTANCE);
|
||||
double value = attribute == null ? Double.NaN : attribute.getValue();
|
||||
if (Double.isFinite(value)) data.put("knockback_resistance", value);
|
||||
else data.put("knockback_resistance_status", "unavailable");
|
||||
} catch (RuntimeException ignored) { data.put("knockback_resistance_status", "unavailable"); }
|
||||
EventInput.Context context = action == null ? null : action.actor();
|
||||
return new Pending(operation, targetId, context, data);
|
||||
}
|
||||
|
||||
private void applied(Pending pending, LivingEntity target) {
|
||||
// CraftEventFactory dispatches Bukkit first, then Paper. Native velocity is written only
|
||||
// after the FINAL Paper result returns. Do not report attempted/cancelled/resisted/no-op pushes.
|
||||
if (pending.bukkit == null || pending.paper == null || pending.paper.isCancelled()
|
||||
|| !Double.isFinite(pending.bukkit.getForce()) || pending.bukkit.getForce() <= 0) return;
|
||||
var delta = pending.paper.getKnockback();
|
||||
if (!finite(delta) || delta.lengthSquared() == 0) return;
|
||||
var data = new LinkedHashMap<>(pending.data);
|
||||
data.put("native_force", pending.bukkit.getForce()); data.put("native_cause", pending.paper.getCause().name());
|
||||
data.put("bukkit_cancelled", pending.bukkit.isCancelled()); data.put("native_final_cancelled", false);
|
||||
data.put("native_velocity_delta", vector(delta));
|
||||
velocity(data, "velocity_after", target); data.put("outcome", "accepted");
|
||||
data.put("native_boundary", "returned_after_final_paper_knockback_and_velocity_application");
|
||||
outbox.accepted(new EventInput(id(pending.action), pending.action, Instant.now(), "effect.applied", pending.actor, data));
|
||||
}
|
||||
|
||||
private static UUID id(UUID action) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:stick-knockback:" + action + ":applied").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
private static void location(Map<String, Object> data, String key, java.util.function.Supplier<org.bukkit.Location> capture) {
|
||||
try {
|
||||
var location = capture.get(); var world = location == null ? null : location.getWorld();
|
||||
if (world == null || !Double.isFinite(location.getX()) || !Double.isFinite(location.getY()) || !Double.isFinite(location.getZ())) {
|
||||
data.put(key + "_status", "unavailable"); return;
|
||||
}
|
||||
data.put(key, Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
|
||||
"x", location.getX(), "y", location.getY(), "z", location.getZ(), "captured_at", Instant.now().toString()));
|
||||
data.put(key + "_status", "exact");
|
||||
} catch (RuntimeException ignored) { data.put(key + "_status", "unavailable"); }
|
||||
}
|
||||
private static void velocity(Map<String, Object> data, String key, LivingEntity target) {
|
||||
try { var value = target.getVelocity(); if (finite(value)) data.put(key, vector(value));
|
||||
else data.put(key + "_status", "unavailable"); }
|
||||
catch (RuntimeException ignored) { data.put(key + "_status", "unavailable"); }
|
||||
}
|
||||
private static boolean finite(Vector value) {
|
||||
return value != null && Double.isFinite(value.getX()) && Double.isFinite(value.getY()) && Double.isFinite(value.getZ());
|
||||
}
|
||||
private static Map<String, Object> vector(Vector value) { return Map.of("x", value.getX(), "y", value.getY(), "z", value.getZ()); }
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import games.dmg.fantasystick.training.TrainingDummy;
|
||||
import games.dmg.fantasystick.training.TrainingDummyController;
|
||||
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.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Immutable acknowledged dummy-owner records; replay has no gameplay authority. */
|
||||
public final class TrainingEventBridge implements AutoCloseable {
|
||||
public record Actor(String kind, UUID playerId, String playerName, EventInput.Context context) {}
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock = Clock.systemUTC();
|
||||
|
||||
public TrainingEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Path data, Consumer<Throwable> failures) {
|
||||
outbox = new FeatureEventOutbox(data, "training-event-intents", Set.of("training_dummy.placed", "training_dummy.removed", "training_dummy.measured"), sink, failures);
|
||||
}
|
||||
|
||||
public Actor actor(CommandSender sender, EquipmentSessions sessions, org.bukkit.Location input) {
|
||||
String kind = sender instanceof Player ? "player" : sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console";
|
||||
if (!(sender instanceof Player player)) return new Actor(kind, null, null, null);
|
||||
UUID id = null, character = null, session = null; String name = null, characterName = null;
|
||||
EventInput.Location location = null;
|
||||
try { id = player.getUniqueId(); } catch (RuntimeException ignored) { }
|
||||
try { name = player.getName(); } catch (RuntimeException ignored) { }
|
||||
try {
|
||||
var active = id == null ? null : sessions.active(id).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
if (record != null) { character = record.id(); characterName = record.name(); session = active.session(); }
|
||||
} catch (RuntimeException ignored) { }
|
||||
try {
|
||||
var at = input == null ? player.getLocation() : input;
|
||||
if (at.getWorld() != null) location = new EventInput.Location(at.getWorld().getUID(), at.getWorld().getName(),
|
||||
at.getX(), at.getY(), at.getZ(), clock.instant(), "native");
|
||||
} catch (RuntimeException ignored) { }
|
||||
try { return new Actor(kind, id, name, new EventInput.Context(id, name, character, characterName, session, location)); }
|
||||
catch (RuntimeException unavailable) { return new Actor(kind, id, name, null); }
|
||||
}
|
||||
|
||||
public void registration(String type, UUID operation, TrainingDummy dummy, Actor actor,
|
||||
TrainingDummyController.Observed entities, String outcome) {
|
||||
try {
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("actor_kind", actor == null ? "unavailable" : actor.kind());
|
||||
values.put("request_actor_kind", actor == null ? "unavailable" : actor.kind());
|
||||
values.put("actor_capture_quality", actor == null || "player".equals(actor.kind()) && actor.context() == null ? "unavailable" : "native");
|
||||
if (actor != null && actor.playerId() != null) values.put("request_player_id", actor.playerId().toString());
|
||||
if (actor != null && actor.playerName() != null) values.put("request_player_name", actor.playerName());
|
||||
values.put("operation_id", operation.toString()); values.put("dummy_id", dummy.id().toString());
|
||||
values.put("dummy_name", dummy.name()); values.put("dummy_display_name", dummy.displayName());
|
||||
values.put("revision_capture_quality", dummy.revision() == null ? "unavailable" : "persisted");
|
||||
if (dummy.revision() != null) values.put("dummy_revision", dummy.revision().toString());
|
||||
values.put("world_id", dummy.world().toString()); values.put("x", dummy.x()); values.put("y", dummy.y());
|
||||
values.put("z", dummy.z()); values.put("yaw", (double) dummy.yaw()); values.put("pitch", (double) dummy.pitch());
|
||||
values.put("chunk_status", entities.chunkStatus()); values.put("entity_capture_quality", entities.quality());
|
||||
values.put("entity_ids", entities.ids().stream().map(UUID::toString).toList());
|
||||
if (entities.ids().size() == 1) values.put("entity_id", entities.ids().getFirst().toString());
|
||||
values.put("entity_outcome", outcome); values.put("outcome", "acknowledged");
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:training:" + operation + ":" + type).getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id, operation, clock.instant(), type, actor == null ? null : actor.context(), Map.copyOf(values)));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot undo registration or native reconciliation. */ }
|
||||
}
|
||||
|
||||
public record Capture(games.dmg.fantasystick.training.TrainingDamage.Source source, String playerName,
|
||||
EventInput.Location location, EventInput.Context context) {}
|
||||
public Capture capture(Player player, games.dmg.fantasystick.training.TrainingDamage.Source source) {
|
||||
String name = null; EventInput.Location location = null; EventInput.Context context = null;
|
||||
try { name = player.getName(); } catch (RuntimeException ignored) { }
|
||||
try {
|
||||
var at = player.getLocation();
|
||||
if (at.getWorld() != null) location = new EventInput.Location(at.getWorld().getUID(), at.getWorld().getName(),
|
||||
at.getX(), at.getY(), at.getZ(), clock.instant(), "native");
|
||||
} catch (RuntimeException ignored) { }
|
||||
try { context = new EventInput.Context(source.playerId(), name, source.characterId(), source.characterName(), source.sessionId(), location); }
|
||||
catch (RuntimeException ignored) { }
|
||||
return new Capture(source, name, location, context);
|
||||
}
|
||||
|
||||
public EventInput measurement(Capture capture, UUID causation, TrainingDummy dummy, org.bukkit.event.entity.EntityDamageEvent event,
|
||||
java.math.BigDecimal amount) {
|
||||
try {
|
||||
var source = capture.source(); var values = new LinkedHashMap<String, Object>();
|
||||
UUID measurement = UUID.nameUUIDFromBytes(("fantasystick:training:" + causation + ":measured").getBytes(StandardCharsets.UTF_8));
|
||||
values.put("measurement_id", measurement.toString()); values.put("damage_causation_id", causation.toString());
|
||||
values.put("attack_name", source.attackName());
|
||||
if (source.abilityId() != null) values.put("ability_id", source.abilityId());
|
||||
values.put("source_player_id", source.playerId().toString()); values.put("source_character_id", source.characterId().toString());
|
||||
values.put("source_session_id", source.sessionId().toString()); values.put("source_character_name", source.characterName());
|
||||
if (capture.playerName() != null) values.put("source_player_name", capture.playerName());
|
||||
values.put("actor_capture_quality", capture.context() == null ? "unavailable" : "native");
|
||||
values.put("dummy_id", dummy.id().toString()); values.put("dummy_name", dummy.name()); values.put("dummy_display_name", dummy.displayName());
|
||||
values.put("revision_capture_quality", dummy.revision() == null ? "unavailable" : "persisted");
|
||||
if (dummy.revision() != null) values.put("dummy_revision", dummy.revision().toString());
|
||||
values.put("entity_id", event.getEntity().getUniqueId().toString()); values.put("final_amount", amount.toPlainString());
|
||||
values.put("native_prior_cancelled", false); values.put("dummy_containment_cancelled", true);
|
||||
var modifiers = new LinkedHashMap<String, String>();
|
||||
boolean completeModifiers = true;
|
||||
for (var modifier : org.bukkit.event.entity.EntityDamageEvent.DamageModifier.values()) {
|
||||
try {
|
||||
if (event.isApplicable(modifier)) modifiers.put(modifier.name().toLowerCase(java.util.Locale.ROOT),
|
||||
java.math.BigDecimal.valueOf(event.getDamage(modifier)).stripTrailingZeros().toPlainString());
|
||||
} catch (RuntimeException unavailable) { completeModifiers = false; }
|
||||
}
|
||||
values.put("native_modifiers", Map.copyOf(modifiers));
|
||||
values.put("native_modifier_capture_quality", completeModifiers ? "native" : "unavailable");
|
||||
projectile(values, event);
|
||||
if (capture.location() != null) values.put("source_location", coordinates(capture.location()));
|
||||
else values.put("source_location_capture_quality", "unavailable");
|
||||
try {
|
||||
var at = event.getEntity().getLocation(); var world = at.getWorld();
|
||||
if (world != null) values.put("target_location", coordinates(new EventInput.Location(world.getUID(), world.getName(),
|
||||
at.getX(), at.getY(), at.getZ(), clock.instant(), "native")));
|
||||
else values.put("target_location_capture_quality", "unavailable");
|
||||
} catch (RuntimeException unavailable) { values.put("target_location_capture_quality", "unavailable"); }
|
||||
values.put("outcome", "measured");
|
||||
return new EventInput(measurement, source.actionId(), clock.instant(), "training_dummy.measured", capture.context(), Map.copyOf(values));
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
private void projectile(Map<String, Object> values, org.bukkit.event.entity.EntityDamageEvent event) {
|
||||
org.bukkit.entity.Entity direct;
|
||||
try { direct = event.getDamageSource().getDirectEntity(); }
|
||||
catch (RuntimeException unavailable) { values.put("direct_entity_capture_quality", "unavailable"); return; }
|
||||
if (!(direct instanceof org.bukkit.entity.Projectile projectile)) return;
|
||||
try {
|
||||
values.put("projectile_id", projectile.getUniqueId().toString());
|
||||
values.put("projectile_capture_quality", "native");
|
||||
} catch (RuntimeException unavailable) { values.put("projectile_capture_quality", "unavailable"); }
|
||||
var flight = new LinkedHashMap<String, Object>();
|
||||
try {
|
||||
var at = projectile.getLocation(); var world = at.getWorld();
|
||||
if (world == null) flight.put("location_capture_quality", "unavailable");
|
||||
else flight.putAll(coordinates(new EventInput.Location(world.getUID(), world.getName(),
|
||||
at.getX(), at.getY(), at.getZ(), clock.instant(), "native")));
|
||||
} catch (RuntimeException unavailable) { flight.put("location_capture_quality", "unavailable"); }
|
||||
try {
|
||||
var velocity = projectile.getVelocity();
|
||||
if (Double.isFinite(velocity.getX()) && Double.isFinite(velocity.getY()) && Double.isFinite(velocity.getZ())) {
|
||||
flight.put("velocity_x", velocity.getX()); flight.put("velocity_y", velocity.getY()); flight.put("velocity_z", velocity.getZ());
|
||||
} else flight.put("velocity_capture_quality", "unavailable");
|
||||
} catch (RuntimeException unavailable) { flight.put("velocity_capture_quality", "unavailable"); }
|
||||
flight.put("capture_boundary", "native_damage_interception");
|
||||
values.put("flight_context", Map.copyOf(flight));
|
||||
}
|
||||
|
||||
private static Map<String, Object> coordinates(EventInput.Location location) {
|
||||
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(), "capture_quality", location.quality());
|
||||
}
|
||||
public void measured(EventInput input) {
|
||||
try { if (input != null) outbox.accepted(input); } catch (RuntimeException ignored) { /* Never gameplay. */ }
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -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 java.util.function.Function<LivingEntity,Map<String,Object>> managedRewardEvidence=ignored->Map.of();
|
||||
private games.dmg.fantasystick.events.CombatRewardEventBridge events;
|
||||
private boolean closed;
|
||||
|
||||
@@ -142,6 +143,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
public void attachManagedOrigins(java.util.function.Function<LivingEntity,java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin>> classifier){
|
||||
if(closed)throw new IllegalStateException("Combat experience is closed");managedOrigins=Objects.requireNonNull(classifier);
|
||||
}
|
||||
public void attachManagedRewardEvidence(java.util.function.Function<LivingEntity,Map<String,Object>> evidence){if(closed)throw new IllegalStateException("Combat experience is closed");managedRewardEvidence=Objects.requireNonNull(evidence);}
|
||||
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);}
|
||||
|
||||
@@ -236,6 +238,9 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
BigInteger defeatedCoins) {
|
||||
Instant now = clock.instant();
|
||||
java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> managed;try{managed=managedOrigins.apply(target);}catch(RuntimeException invalid){managed=java.util.Optional.empty();}
|
||||
Map<String,Object> managedEvidence=Map.of();
|
||||
if(managed.isPresent())try{managedEvidence=Map.copyOf(managedRewardEvidence.apply(target));}
|
||||
catch(RuntimeException ignored){}
|
||||
var direct = new ArrayList<>(contributions.consumeDirect(target.getUniqueId(), now));
|
||||
if (defeatedCharacter != null) direct.remove(defeatedCharacter);
|
||||
if (direct.isEmpty()) {managed.ifPresent(this::settled);return;}
|
||||
@@ -255,7 +260,7 @@ 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 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 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,managedEvidence);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.ability.AbilityReport;
|
||||
import games.dmg.fantasystick.events.HealthStealEventBridge;
|
||||
import games.dmg.fantasystick.party.CombatSources;
|
||||
import games.dmg.fantasystick.presentation.CombatFeedback;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
@@ -46,8 +47,8 @@ public final class HealthSteal implements Listener, AutoCloseable {
|
||||
public static Settings defaults() { return new Settings(BigInteger.ONE); }
|
||||
}
|
||||
public record Failure(UUID character, Throwable cause) { }
|
||||
private record Candidate(LivingEntity target, CombatSources.Origin origin) { }
|
||||
private record Heal(EquipmentSessions.Active active, double amount) { }
|
||||
private record Candidate(LivingEntity target, CombatSources.Origin origin, HealthStealEventBridge.Damage audit) { }
|
||||
private record Heal(EquipmentSessions.Active active, double amount, HealthStealEventBridge.Action audit) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
@@ -80,6 +81,11 @@ public final class HealthSteal implements Listener, AutoCloseable {
|
||||
this.failures = Objects.requireNonNull(failures); this.feedback = feedback; sources = new CombatSources(sessions);
|
||||
}
|
||||
|
||||
private HealthStealEventBridge events;
|
||||
public void attachEvents(HealthStealEventBridge bridge) {
|
||||
if (closed || events != null || !lethal.isEmpty() || !pending.isEmpty()) throw new IllegalStateException("Health Steal events already attached or evaluation started");
|
||||
events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
|
||||
public BigInteger healing() { return settings.healing(); }
|
||||
public PassiveDefinition definition() {
|
||||
@@ -102,7 +108,7 @@ public final class HealthSteal implements Listener, AutoCloseable {
|
||||
|| event.getFinalDamage() < remaining) {
|
||||
lethal.remove(targetId); return;
|
||||
}
|
||||
lethal.put(targetId, new Candidate(target, origin));
|
||||
lethal.put(targetId, new Candidate(target, origin, events == null ? null : events.damage(event)));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
||||
@@ -151,58 +157,75 @@ public final class HealthSteal implements Listener, AutoCloseable {
|
||||
double amount = configured.bitLength() > 1023 ? maximum - current - reserved : configured.doubleValue();
|
||||
double proposed = Math.min(maximum - current - reserved, amount);
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || current < 0 || maximum <= 0
|
||||
|| !Double.isFinite(proposed) || proposed <= 0) return;
|
||||
|| !Double.isFinite(proposed)) return;
|
||||
var audit = events == null ? null : events.begin(candidate.audit(), target, player, active, record,
|
||||
target instanceof Player ? sessions.active(target.getUniqueId()).orElse(null) : null,
|
||||
configured, proposed, current, maximum);
|
||||
if (proposed <= 0) { if (events != null) events.refused(audit, "full_health_or_no_available_capacity"); return; }
|
||||
var healing = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(healing);
|
||||
try { healingEvents.accept(healing); }
|
||||
catch (RuntimeException failure) { if (events != null) events.failed(audit, "native_healing_validation_failed"); throw failure; }
|
||||
double accepted = Math.min(proposed, Math.min(maximum - current - reserved, healing.getAmount()));
|
||||
if (healing.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
|
||||
if (healing.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {
|
||||
if (events != null) events.refused(audit, healing.isCancelled() ? "native_event_cancelled" : "no_positive_healing_accepted"); return;
|
||||
}
|
||||
var queue = pending.computeIfAbsent(active.character(), ignored -> new ArrayDeque<>());
|
||||
if (queue.size() >= MAX_PENDING_PER_CHARACTER) return;
|
||||
queue.addLast(new Heal(active, accepted));
|
||||
if (queue.size() >= MAX_PENDING_PER_CHARACTER) { if (events != null) events.refused(audit, "pending_heal_capacity"); return; }
|
||||
queue.addLast(new Heal(active, accepted, audit));
|
||||
if (queue.size() == 1) attempt(active.character());
|
||||
}
|
||||
|
||||
private void attempt(UUID character) {
|
||||
var queue = pending.get(character); var operation = queue == null ? null : queue.peekFirst();
|
||||
if (closed || operation == null) return;
|
||||
if (!sessions.current(operation.active())) { finish(character); return; }
|
||||
if (!sessions.current(operation.active())) { auditFailed(operation, "source_session_changed"); finish(character); return; }
|
||||
var record = sessions.currentRecord(operation.active()).orElse(null);
|
||||
var player = sessions.currentPlayer(operation.active()).orElse(null);
|
||||
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) { finish(character); return; }
|
||||
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) {
|
||||
auditFailed(operation, "character_unavailable_or_passive_removed"); finish(character); return;
|
||||
}
|
||||
double nativeBefore = player.getHealth();
|
||||
BigDecimal target = record.health().add(BigDecimal.valueOf(operation.amount()));
|
||||
BigDecimal limit;
|
||||
try { limit = nativeLimit.get(); }
|
||||
catch (RuntimeException failure) { report(character, failure); finish(character); return; }
|
||||
catch (RuntimeException failure) { auditFailed(operation, "native_health_limit_unavailable"); report(character, failure); finish(character); return; }
|
||||
BigInteger expected = record.revision();
|
||||
characters.saveHealth(character, expected, target, limit).whenComplete((saved, failure) -> {
|
||||
try { nextTick.accept(() -> complete(character, operation, expected, nativeBefore, saved, failure)); }
|
||||
catch (RuntimeException rejected) { report(character, rejected); }
|
||||
try { nextTick.accept(() -> complete(character, operation, expected, record, nativeBefore, saved, failure)); }
|
||||
catch (RuntimeException rejected) { auditFailed(operation, "scheduler_rejected_after_save_uncertain"); report(character, rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void complete(UUID character, Heal operation, BigInteger expected, double nativeBefore,
|
||||
private void complete(UUID character, Heal operation, BigInteger expected, CharacterRecord before, double nativeBefore,
|
||||
java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
try {
|
||||
if (closed || failure != null || saved == null || saved.isEmpty()) {
|
||||
auditFailed(operation, closed ? "closed_after_save" : failure != null ? "health_save_failed" : "character_unavailable");
|
||||
if (failure != null) report(character, failure); return;
|
||||
}
|
||||
var record = saved.orElseThrow();
|
||||
if (!sessions.current(operation.active())
|
||||
|| !sessions.acknowledgeProgress(operation.active(), expected, record)) return;
|
||||
|| !sessions.acknowledgeProgress(operation.active(), expected, record)) {
|
||||
auditFailed(operation, "saved_but_session_publication_refused"); return;
|
||||
}
|
||||
var player = sessions.currentPlayer(operation.active()).orElse(null);
|
||||
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) return;
|
||||
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) {
|
||||
auditFailed(operation, "saved_but_native_health_changed"); return;
|
||||
}
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) return;
|
||||
if (attribute == null) { auditFailed(operation, "saved_but_maximum_health_unavailable"); return; }
|
||||
double maximum = Math.min(attribute.getValue(), record.maximumHealth().doubleValue());
|
||||
double publish = Math.min(maximum, record.health().doubleValue());
|
||||
if (Double.isFinite(maximum) && Double.isFinite(publish) && publish > nativeBefore) {
|
||||
player.setHealth(publish);
|
||||
try { player.setHealth(publish); }
|
||||
catch (RuntimeException nativeFailure) { auditFailed(operation, "native_health_publication_failed"); throw nativeFailure; }
|
||||
if (events != null) events.healed(operation.audit(), before, record, nativeBefore, publish, maximum, operation.amount());
|
||||
if (feedback != null) feedback.report("Health Steal", player, AbilityReport.Type.HEALING, player,
|
||||
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
|
||||
}
|
||||
} else auditFailed(operation, "saved_but_no_positive_native_heal");
|
||||
} finally { finish(character); }
|
||||
}
|
||||
private void auditFailed(Heal operation, String reason) { if (events != null) events.failed(operation.audit(), reason); }
|
||||
|
||||
private void finish(UUID character) {
|
||||
var queue = pending.get(character);
|
||||
|
||||
@@ -23,6 +23,7 @@ public final class LowLightVision implements AutoCloseable {
|
||||
"Preserves unrelated Night Vision and does not change light, darkness or mob behavior."));
|
||||
}
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.PassivePotionEventBridge bridge) { effect.attachEvents(bridge); }
|
||||
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
|
||||
public void sweep() { effect.sweep(); }
|
||||
@Override public void close() { effect.close(); }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import games.dmg.fantasystick.events.PassivePotionEventBridge;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -20,6 +21,8 @@ final class PersistentPotionEffect implements AutoCloseable {
|
||||
PotionEffect displaced;
|
||||
long displacedUntil;
|
||||
long refreshedAt;
|
||||
long applications;
|
||||
PassivePotionEventBridge.Application application;
|
||||
State(EquipmentSessions.Active active, Player player) { this.active = active; this.player = player; }
|
||||
|
||||
void refresh() {
|
||||
@@ -28,8 +31,11 @@ final class PersistentPotionEffect implements AutoCloseable {
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(type); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (owned != null && !same(current, owned)) { owned = null; displaced = null; }
|
||||
if (!eligible()) { remove(); return; }
|
||||
if (owned != null && !same(current, owned)) {
|
||||
endAudit("native_layer_replaced_or_removed", current == null ? "native_layer_absent" : "different_native_layer_preserved", current, false);
|
||||
owned = null; displaced = null;
|
||||
}
|
||||
if (!eligible()) { remove(endReason()); return; }
|
||||
if (current != null && (!replaceWeaker || current.getAmplifier() >= amplifier)) return;
|
||||
var effect = new PotionEffect(type, PotionEffect.INFINITE_DURATION, amplifier, false, false, true);
|
||||
try {
|
||||
@@ -37,6 +43,14 @@ final class PersistentPotionEffect implements AutoCloseable {
|
||||
owned = effect; displaced = current;
|
||||
displacedUntil = current == null ? 0 : current.isInfinite()
|
||||
? Long.MAX_VALUE : sweepGeneration + current.getDuration();
|
||||
if (events != null) {
|
||||
try {
|
||||
var record = sessions.currentRecord(active).orElse(null);
|
||||
if (record != null) application = events.applied(passive, player, active, record.name(), owned, current,
|
||||
applications == 0 ? "learned_current_session" : "owned_layer_reestablished");
|
||||
} catch (RuntimeException ignored) { /* Audit capture cannot discard native ownership. */ }
|
||||
}
|
||||
applications++;
|
||||
}
|
||||
} catch (RuntimeException unavailable) { owned = null; displaced = null; }
|
||||
}
|
||||
@@ -47,7 +61,21 @@ final class PersistentPotionEffect implements AutoCloseable {
|
||||
return record != null && !record.dead() && record.passives().contains(passive);
|
||||
}
|
||||
|
||||
void remove() {
|
||||
String endReason() {
|
||||
if (events == null) return "owner_no_longer_eligible";
|
||||
try {
|
||||
if (closed) return "plugin_shutdown";
|
||||
if (!sessions.current(active)) return "session_no_longer_current";
|
||||
var record = sessions.currentRecord(active).orElse(null);
|
||||
if (record != null && record.dead()) return "character_defeated";
|
||||
return "passive_unlearned_or_character_unavailable";
|
||||
} catch (RuntimeException ignored) { return "owner_no_longer_eligible"; }
|
||||
}
|
||||
void endAudit(String reason, String disposition, PotionEffect current, boolean restored) {
|
||||
if (events != null && application != null) events.ended(application, player, reason, disposition, current, restored);
|
||||
application = null;
|
||||
}
|
||||
void remove(String reason) {
|
||||
if (owned != null) {
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(type); }
|
||||
@@ -55,14 +83,21 @@ final class PersistentPotionEffect implements AutoCloseable {
|
||||
if (same(current, owned)) {
|
||||
try {
|
||||
player.removePotionEffect(type);
|
||||
boolean restored = false;
|
||||
if (displaced != null && (displaced.isInfinite() || sweepGeneration < displacedUntil)) {
|
||||
int duration = displaced.isInfinite() ? PotionEffect.INFINITE_DURATION
|
||||
: Math.toIntExact(Math.min(Integer.MAX_VALUE, displacedUntil - sweepGeneration));
|
||||
player.addPotionEffect(new PotionEffect(displaced.getType(), duration, displaced.getAmplifier(),
|
||||
restored = player.addPotionEffect(new PotionEffect(displaced.getType(), duration, displaced.getAmplifier(),
|
||||
displaced.isAmbient(), displaced.hasParticles(), displaced.hasIcon()), true);
|
||||
}
|
||||
if (events != null) {
|
||||
try {
|
||||
var after = player.getPotionEffect(type);
|
||||
if (!same(after, owned)) endAudit(reason, "owned_layer_removed", after, restored);
|
||||
} catch (RuntimeException unavailable) { /* No native acknowledgement: do not invent an end record. */ }
|
||||
}
|
||||
} catch (RuntimeException unavailable) { return; }
|
||||
}
|
||||
} else endAudit(reason, current == null ? "native_layer_absent" : "different_native_layer_preserved", current, false);
|
||||
owned = null; displaced = null;
|
||||
}
|
||||
states.remove(active.player(), this);
|
||||
@@ -80,6 +115,12 @@ final class PersistentPotionEffect implements AutoCloseable {
|
||||
private long sweepGeneration;
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
private PassivePotionEventBridge events;
|
||||
|
||||
void attachEvents(PassivePotionEventBridge bridge) {
|
||||
if (closed || events != null || !states.isEmpty()) throw new IllegalStateException("Passive potion events already attached or evaluation started");
|
||||
events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
PersistentPotionEffect(String passive, PotionEffectType type, int amplifier, boolean replaceWeaker,
|
||||
EquipmentSessions sessions) {
|
||||
@@ -119,7 +160,7 @@ final class PersistentPotionEffect implements AutoCloseable {
|
||||
var state = states.get(active.player());
|
||||
boolean learned = !snapshot.record().dead() && snapshot.record().passives().contains(passive);
|
||||
if (!learned || !sessions.current(active)) { if (state != null) state.refresh(); return; }
|
||||
if (state != null && !state.active.equals(active)) { state.remove(); state = states.get(active.player()); }
|
||||
if (state != null && !state.active.equals(active)) { state.remove("owner_session_replaced"); state = states.get(active.player()); }
|
||||
if (state == null) {
|
||||
var player = sessions.currentPlayer(active).orElse(null);
|
||||
if (player == null) return;
|
||||
@@ -139,7 +180,7 @@ final class PersistentPotionEffect implements AutoCloseable {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (sweepTask != null) sweepTask.cancel();
|
||||
for (var state : List.copyOf(states.values())) state.remove();
|
||||
for (var state : List.copyOf(states.values())) state.remove("plugin_shutdown");
|
||||
states.clear();
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public final class SpeedI implements AutoCloseable {
|
||||
"Does not stack with equal Speed I and preserves stronger unrelated Speed effects."));
|
||||
}
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.PassivePotionEventBridge bridge) { effect.attachEvents(bridge); }
|
||||
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
|
||||
public void sweep() { effect.sweep(); }
|
||||
@Override public void close() { effect.close(); }
|
||||
|
||||
@@ -194,6 +194,10 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
|
||||
public CompletionStage<Void> acknowledgeEvent(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"),
|
||||
@@ -208,9 +212,12 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
synchronized (this) { pendingEvents.remove(action); }
|
||||
} catch (IOException | OverlappingFileLockException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure);
|
||||
}
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException closedWorker) { return CompletableFuture.failedStage(closedWorker); }
|
||||
} catch (RejectedExecutionException closedWorker) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(closedWorker);
|
||||
}
|
||||
}
|
||||
|
||||
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled) {
|
||||
|
||||
@@ -21,8 +21,8 @@ public final class YamlAdventureQuestRepository {
|
||||
if(Files.notExists(file))return AdventureQuestState.empty();
|
||||
try{
|
||||
Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));var root=map(decoded);int schema=number(root.get("schema-version"));if(schema!=1&&schema!=2)throw new IllegalArgumentException();legacyDungeonRewardTerms=schema==1;
|
||||
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);AdventureQuestState.Arena arena=null;if(r.containsKey("arena-shape")){var shape=AdventureQuestState.ArenaShape.valueOf(text(r.get("arena-shape")));int x=number(r.get("arena-x")),z=number(r.get("arena-z"));arena=new AdventureQuestState.Arena(shape,x,z);}AdventureQuestState.FishingDefinition fishing=null;if(r.get("fishing")!=null){var f=map(r.get("fishing"));fishing=new AdventureQuestState.FishingDefinition(uuid(f,"item-id"),text(f.get("item-name")),new BigInteger(text(f.get("required"))),new java.math.BigDecimal(text(f.get("chance-percent"))),new BigInteger(text(f.get("reward-xp"))),new BigInteger(text(f.get("reward-coins"))),number(f.get("radius")));}sites.add(new AdventureQuestState.Site(uuid(r,"id"),AdventureQuestState.SiteType.valueOf(text(r.get("type"))),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),arena,r.containsKey("participation-mode")?AdventureQuestState.ParticipationMode.parse(text(r.get("participation-mode"))):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,fishing));}
|
||||
var givers=new ArrayList<AdventureQuestState.Giver>();for(Object item:list(root.get("givers"))){var r=map(item);givers.add(new AdventureQuestState.Giver(uuid(r,"id"),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),(float)decimal(r,"yaw"),(float)decimal(r,"pitch")));}
|
||||
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);AdventureQuestState.Arena arena=null;if(r.containsKey("arena-shape")){var shape=AdventureQuestState.ArenaShape.valueOf(text(r.get("arena-shape")));int x=number(r.get("arena-x")),z=number(r.get("arena-z"));arena=new AdventureQuestState.Arena(shape,x,z);}AdventureQuestState.FishingDefinition fishing=null;if(r.get("fishing")!=null){var f=map(r.get("fishing"));fishing=new AdventureQuestState.FishingDefinition(uuid(f,"item-id"),text(f.get("item-name")),new BigInteger(text(f.get("required"))),new java.math.BigDecimal(text(f.get("chance-percent"))),new BigInteger(text(f.get("reward-xp"))),new BigInteger(text(f.get("reward-coins"))),number(f.get("radius")));}sites.add(new AdventureQuestState.Site(uuid(r,"id"),AdventureQuestState.SiteType.valueOf(text(r.get("type"))),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),arena,r.containsKey("participation-mode")?AdventureQuestState.ParticipationMode.parse(text(r.get("participation-mode"))):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,fishing,r.containsKey("registration-revision")?uuid(r,"registration-revision"):null));}
|
||||
var givers=new ArrayList<AdventureQuestState.Giver>();for(Object item:list(root.get("givers"))){var r=map(item);givers.add(new AdventureQuestState.Giver(uuid(r,"id"),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),(float)decimal(r,"yaw"),(float)decimal(r,"pitch"),r.containsKey("registration-revision")?uuid(r,"registration-revision"):null));}
|
||||
AdventureQuestState.Catalog catalog=null;if(root.get("catalog")!=null){var r=map(root.get("catalog"));catalog=new AdventureQuestState.Catalog(LocalDate.parse(text(r.get("day"))),uuid(r,"revision"),Instant.parse(text(r.get("created-at"))),list(r.get("sites")).stream().map(value->UUID.fromString(text(value))).toList(),r.containsKey("closed")?list(r.get("closed")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()):Set.of());}
|
||||
var quests=new ArrayList<AdventureQuestState.Quest>();for(Object item:list(root.get("quests"))){var r=map(item);quests.add(new AdventureQuestState.Quest(uuid(r,"id"),uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit"),list(r.get("snapshot")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()),Instant.parse(text(r.get("expires-at"))),new BigInteger(text(r.get("reward-xp"))),new BigInteger(text(r.get("reward-coins"))),r.containsKey("participation-mode")?AdventureQuestState.ParticipationMode.parse(text(r.get("participation-mode"))):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,r.get("fishing")==null?null:fishingTerms(map(r.get("fishing")))));}
|
||||
var unitClaims=new HashSet<AdventureQuestState.UnitClaim>();for(Object item:list(root.get("unit-claims"))){var r=map(item);unitClaims.add(new AdventureQuestState.UnitClaim(uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit")));}
|
||||
@@ -48,7 +48,7 @@ public final class YamlAdventureQuestRepository {
|
||||
public void save(AdventureQuestState state)throws IOException{
|
||||
Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",2);
|
||||
root.put("sites",state.sites().stream().sorted(Comparator.comparing(AdventureQuestState.Site::id)).map(YamlAdventureQuestRepository::siteRow).toList());
|
||||
root.put("givers",state.givers().stream().sorted(Comparator.comparing(AdventureQuestState.Giver::id)).map(v->row("id",v.id(),"name",v.name(),"world",v.world(),"x",v.x(),"y",v.y(),"z",v.z(),"yaw",v.yaw(),"pitch",v.pitch())).toList());
|
||||
root.put("givers",state.givers().stream().sorted(Comparator.comparing(AdventureQuestState.Giver::id)).map(YamlAdventureQuestRepository::giverRow).toList());
|
||||
if(state.catalog()!=null)root.put("catalog",row("day",state.catalog().day(),"revision",state.catalog().revision(),"created-at",state.catalog().createdAt(),"sites",state.catalog().sites(),"closed",state.catalog().closed().stream().sorted().toList()));else root.put("catalog",null);
|
||||
root.put("quests",state.quests().stream().sorted(Comparator.comparing(AdventureQuestState.Quest::id)).map(v->row("id",v.id(),"catalog",v.catalog(),"site",v.site(),"unit-type",v.unitType(),"unit",v.unit(),"snapshot",v.snapshot().stream().sorted().toList(),"expires-at",v.expiresAt(),"reward-xp",v.rewardXp(),"reward-coins",v.rewardCoins(),"participation-mode",v.participationMode().value(),"fishing",v.fishing()==null?null:fishing(v.fishing()))).toList());
|
||||
root.put("unit-claims",state.unitClaims().stream().sorted(Comparator.comparing(v->v.catalog().toString()+v.site()+v.unit())).map(v->row("catalog",v.catalog(),"site",v.site(),"unit-type",v.type(),"unit",v.unit())).toList());
|
||||
@@ -59,7 +59,8 @@ public final class YamlAdventureQuestRepository {
|
||||
root.put("reset",state.reset()==null?null:row("operation",state.reset().operation(),"generation",state.reset().generation(),"started-at",state.reset().startedAt()));
|
||||
Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"adventure-quests-",".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 adventure persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}legacyDungeonRewardTerms=false;
|
||||
}
|
||||
private static Map<String,Object> siteRow(AdventureQuestState.Site site){var result=row("id",site.id(),"type",site.type(),"name",site.name(),"world",site.world(),"x",site.x(),"y",site.y(),"z",site.z(),"participation-mode",site.participationMode().value(),"fishing",site.fishing()==null?null:fishing(site.fishing().terms()));if(site.arena()!=null){result.put("arena-shape",encode(site.arena().shape()));result.put("arena-x",site.arena().xBlocks());result.put("arena-z",site.arena().zBlocks());}return result;}
|
||||
private static Map<String,Object> siteRow(AdventureQuestState.Site site){var result=row("id",site.id(),"type",site.type(),"name",site.name(),"world",site.world(),"x",site.x(),"y",site.y(),"z",site.z(),"participation-mode",site.participationMode().value(),"fishing",site.fishing()==null?null:fishing(site.fishing().terms()));if(site.registrationRevision()!=null)result.put("registration-revision",site.registrationRevision().toString());if(site.arena()!=null){result.put("arena-shape",encode(site.arena().shape()));result.put("arena-x",site.arena().xBlocks());result.put("arena-z",site.arena().zBlocks());}return result;}
|
||||
private static Map<String,Object> giverRow(AdventureQuestState.Giver giver){var result=row("id",giver.id(),"name",giver.name(),"world",giver.world(),"x",giver.x(),"y",giver.y(),"z",giver.z(),"yaw",giver.yaw(),"pitch",giver.pitch());if(giver.registrationRevision()!=null)result.put("registration-revision",giver.registrationRevision().toString());return result;}
|
||||
private static Map<String,Object> fishing(AdventureQuestState.FishingTerms value){return row("item-id",value.itemId(),"item-name",value.itemName(),"required",value.required(),"chance-percent",value.chancePercent(),"reward-xp",value.rewardXp(),"reward-coins",value.rewardCoins(),"radius",value.radius());}
|
||||
private static AdventureQuestState.FishingTerms fishingTerms(Map<?,?> value){return new AdventureQuestState.FishingTerms(uuid(value,"item-id"),text(value.get("item-name")),new BigInteger(text(value.get("required"))),new java.math.BigDecimal(text(value.get("chance-percent"))),new BigInteger(text(value.get("reward-xp"))),new BigInteger(text(value.get("reward-coins"))),number(value.get("radius")));}
|
||||
private static Map<String,Object> row(Object...pairs){var row=new LinkedHashMap<String,Object>();for(int i=0;i<pairs.length;i+=2)row.put((String)pairs[i],encode(pairs[i+1]));return row;}
|
||||
|
||||
@@ -18,7 +18,7 @@ public final class YamlDungeonRepository {
|
||||
public DungeonState load()throws IOException{
|
||||
if(Files.notExists(file))return DungeonState.empty();
|
||||
try{var root=map(yaml().load(Files.readString(file,StandardCharsets.UTF_8)));int schema=integer(root.get("schema-version"));if(schema!=1&&schema!=2)throw new IllegalArgumentException();var dungeons=new ArrayList<DungeonState.Dungeon>();for(var item:list(root.get("dungeons"))){var row=map(item);var rooms=new ArrayList<DungeonState.Room>();for(var roomItem:list(row.get("rooms"))){var room=map(roomItem);var mobs=new ArrayList<DungeonState.Spawn>();for(var mobItem:list(room.get("mobs"))){var mob=map(mobItem);mobs.add(new DungeonState.Spawn(uuid(mob,"id"),EntityType.valueOf(text(mob.get("type"))),text(mob.get("name")),position(map(mob.get("position")))));}var effects=new ArrayList<DungeonState.Effect>();for(var effectItem:list(room.get("effects"))){var effect=map(effectItem);effects.add(new DungeonState.Effect(uuid(effect,"id"),DungeonState.EffectTarget.valueOf(text(effect.get("target"))),text(effect.get("effect")),integer(effect.get("level")),bool(effect.get("particles"))));}DungeonState.Boss boss=null;if(room.get("boss")!=null){var value=map(room.get("boss"));DungeonState.CharacterBoss character=null;if(value.get("character")!=null){var custody=map(value.get("character"));DungeonState.Recovery recovery=null;if(custody.get("recovery")!=null){var pending=map(custody.get("recovery"));recovery=new DungeonState.Recovery(uuid(pending,"operation"),uuid(pending,"administrator"));}character=new DungeonState.CharacterBoss(uuid(custody,"operation"),CharacterYaml.decode(text(custody.get("record"))),new java.math.BigDecimal(text(custody.get("multiplier"))),custody.containsKey("prior-type")?EntityType.valueOf(text(custody.get("prior-type"))):EntityType.HUSK,custody.containsKey("prior-name")?text(custody.get("prior-name")):"",recovery);}boss=new DungeonState.Boss(EntityType.valueOf(text(value.get("type"))),text(value.get("name")),position(map(value.get("position"))),character);}rooms.add(new DungeonState.Room(uuid(room,"id"),text(room.get("name")),bounds(map(room.get("bounds"))),mobs,effects,new DungeonState.Reward(new BigInteger(text(room.get("reward-xp"))),new BigInteger(text(room.get("reward-coins")))),boss));}var mode=row.containsKey("participation-mode")?games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(text(row.get("participation-mode"))):games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var completion=schema==2?new DungeonState.Reward(new BigInteger(text(row.get("completion-reward-xp"))),new BigInteger(text(row.get("completion-reward-coins")))):rooms.stream().filter(room->room.boss()!=null).map(DungeonState.Room::reward).findFirst().orElse(DungeonState.Reward.empty());if(schema==1)for(int index=0;index<rooms.size();index++){var room=rooms.get(index);rooms.set(index,new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),DungeonState.Reward.empty(),room.boss()));}dungeons.add(new DungeonState.Dungeon(uuid(row,"id"),text(row.get("name")),uuid(row,"world"),position(map(row.get("entrance"))),bool(row.get("enabled")),uuid(row,"revision"),rooms,mode,completion,schema==1||bool(row.get("completion-reward-configured"))));}
|
||||
var runs=new ArrayList<DungeonState.Run>();for(var item:list(root.get("runs"))){var row=map(item);var unitClaims=new HashSet<DungeonState.UnitClaim>();for(var value:list(row.get("unit-claims"))){var claim=map(value);unitClaims.add(new DungeonState.UnitClaim(uuid(claim,"room"),uuid(claim,"quest")));}var characterClaims=new HashSet<DungeonState.CharacterClaim>();for(var value:list(row.get("character-claims"))){var claim=map(value);characterClaims.add(new DungeonState.CharacterClaim(uuid(claim,"room"),uuid(claim,"character")));}runs.add(new DungeonState.Run(uuid(row,"dungeon"),uuid(row,"generation"),uuids(row.get("defeated-slots")),uuids(row.get("cleared-rooms")),unitClaims,characterClaims,bool(row.get("defeated"))));}
|
||||
var runs=new ArrayList<DungeonState.Run>();for(var item:list(root.get("runs"))){var row=map(item);var unitClaims=new HashSet<DungeonState.UnitClaim>();for(var value:list(row.get("unit-claims"))){var claim=map(value);unitClaims.add(new DungeonState.UnitClaim(uuid(claim,"room"),uuid(claim,"quest")));}var characterClaims=new HashSet<DungeonState.CharacterClaim>();for(var value:list(row.get("character-claims"))){var claim=map(value);characterClaims.add(new DungeonState.CharacterClaim(uuid(claim,"room"),uuid(claim,"character")));}var audit=new LinkedHashMap<String,String>();if(row.get("completion-audit")!=null)for(var entry:map(row.get("completion-audit")).entrySet())audit.put(text(entry.getKey()),text(entry.getValue()));runs.add(new DungeonState.Run(uuid(row,"dungeon"),uuid(row,"generation"),uuids(row.get("defeated-slots")),uuids(row.get("cleared-rooms")),unitClaims,characterClaims,bool(row.get("defeated")),audit,row.containsKey("completion-published")&&bool(row.get("completion-published"))));}
|
||||
return new DungeonState(dungeons,runs);
|
||||
}catch(RuntimeException invalid){throw new IOException("Invalid dungeon file",invalid);}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ public final class YamlDungeonRepository {
|
||||
private static Map<String,Object> dungeon(DungeonState.Dungeon value){return row("id",value.id(),"name",value.name(),"world",value.world(),"entrance",position(value.entrance()),"enabled",value.enabled(),"revision",value.revision(),"participation-mode",value.participationMode().value(),"completion-reward-xp",value.completionReward().xp(),"completion-reward-coins",value.completionReward().coins(),"completion-reward-configured",value.rewardConfigured(),"rooms",value.rooms().stream().sorted(Comparator.comparing(DungeonState.Room::id)).map(YamlDungeonRepository::room).toList());}
|
||||
private static Map<String,Object> room(DungeonState.Room value){return row("id",value.id(),"name",value.name(),"bounds",bounds(value.bounds()),"mobs",value.mobs().stream().sorted(Comparator.comparing(DungeonState.Spawn::id)).map(v->row("id",v.id(),"type",v.type(),"name",v.name(),"position",position(v.position()))).toList(),"effects",value.effects().stream().sorted(Comparator.comparing(DungeonState.Effect::id)).map(v->row("id",v.id(),"target",v.target(),"effect",v.effect(),"level",v.level(),"particles",v.particles())).toList(),"reward-xp",value.reward().xp(),"reward-coins",value.reward().coins(),"boss",value.boss()==null?null:row("type",value.boss().type(),"name",value.boss().name(),"position",position(value.boss().position()),"character",value.boss().character()==null?null:character(value.boss().character())));}
|
||||
private static Map<String,Object> character(DungeonState.CharacterBoss value){return row("operation",value.operation(),"record",CharacterYaml.encode(value.character()),"multiplier",value.multiplier().toPlainString(),"prior-type",value.priorType(),"prior-name",value.priorName(),"recovery",value.recovery()==null?null:row("operation",value.recovery().operation(),"administrator",value.recovery().administrator()));}
|
||||
private static Map<String,Object> run(DungeonState.Run value){return row("dungeon",value.dungeon(),"generation",value.generation(),"defeated-slots",value.defeatedSlots().stream().sorted().toList(),"cleared-rooms",value.clearedRooms().stream().sorted().toList(),"unit-claims",value.unitClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.quest())).map(v->row("room",v.room(),"quest",v.quest())).toList(),"character-claims",value.characterClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.character())).map(v->row("room",v.room(),"character",v.character())).toList(),"defeated",value.defeated());}
|
||||
private static Map<String,Object> run(DungeonState.Run value){return row("dungeon",value.dungeon(),"generation",value.generation(),"defeated-slots",value.defeatedSlots().stream().sorted().toList(),"cleared-rooms",value.clearedRooms().stream().sorted().toList(),"unit-claims",value.unitClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.quest())).map(v->row("room",v.room(),"quest",v.quest())).toList(),"character-claims",value.characterClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.character())).map(v->row("room",v.room(),"character",v.character())).toList(),"defeated",value.defeated(),"completion-audit",new java.util.TreeMap<>(value.completionAudit()),"completion-published",value.completionPublished());}
|
||||
private static Map<String,Object> position(DungeonState.Position value){return row("x",value.x(),"y",value.y(),"z",value.z(),"yaw",value.yaw(),"pitch",value.pitch());}
|
||||
private static DungeonState.Position position(Map<?,?> value){return new DungeonState.Position(decimal(value.get("x")),decimal(value.get("y")),decimal(value.get("z")),(float)decimal(value.get("yaw")),(float)decimal(value.get("pitch")));}
|
||||
private static Map<String,Object> bounds(DungeonState.Bounds value){return row("min-x",value.minX(),"min-y",value.minY(),"min-z",value.minZ(),"max-x",value.maxX(),"max-y",value.maxY(),"max-z",value.maxZ());}
|
||||
|
||||
@@ -3,7 +3,7 @@ import games.dmg.fantasystick.shop.FantasyArmorer;import java.io.*;import java.n
|
||||
/** Forced atomic desired-state adapter for Fantasy Armorer registrations. */
|
||||
public final class YamlFantasyArmorerRepository{
|
||||
private final Path file;public YamlFantasyArmorerRepository(Path data){file=data.toAbsolutePath().resolve("fantasy-armorers.yml");}
|
||||
public List<FantasyArmorer> load()throws IOException{if(Files.notExists(file))return List.of();try{Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||Integer.parseInt(root.get("schema-version").toString())!=1)throw new IllegalArgumentException();var result=new ArrayList<FantasyArmorer>();for(Object value:list(root.get("shops"))){var row=map(value);result.add(new FantasyArmorer(UUID.fromString(text(row,"shop-id")),text(row,"name"),UUID.fromString(text(row,"world")),Double.parseDouble(text(row,"x")),Double.parseDouble(text(row,"y")),Double.parseDouble(text(row,"z")),Float.parseFloat(text(row,"yaw")),Float.parseFloat(text(row,"pitch"))));}validate(result);return List.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid Fantasy Armorer file",invalid);}}
|
||||
public void save(List<FantasyArmorer> values)throws IOException{validate(values);Map<String,Object> root=new LinkedHashMap<>();root.put("schema-version",1);root.put("shops",values.stream().sorted(Comparator.comparing(FantasyArmorer::id)).map(value->{Map<String,Object> row=new LinkedHashMap<>();row.put("shop-id",value.id().toString());row.put("name",value.name());row.put("world",value.world().toString());row.put("x",Double.toString(value.x()));row.put("y",Double.toString(value.y()));row.put("z",Double.toString(value.z()));row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));return row;}).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"armorers-",".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 Armorer persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
|
||||
public List<FantasyArmorer> load()throws IOException{if(Files.notExists(file))return List.of();try{Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||Integer.parseInt(root.get("schema-version").toString())!=1)throw new IllegalArgumentException();var result=new ArrayList<FantasyArmorer>();for(Object value:list(root.get("shops"))){var row=map(value);result.add(new FantasyArmorer(UUID.fromString(text(row,"shop-id")),text(row,"name"),UUID.fromString(text(row,"world")),Double.parseDouble(text(row,"x")),Double.parseDouble(text(row,"y")),Double.parseDouble(text(row,"z")),Float.parseFloat(text(row,"yaw")),Float.parseFloat(text(row,"pitch")),row.containsKey("registration-revision")?UUID.fromString(text(row,"registration-revision")):null));}validate(result);return List.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid Fantasy Armorer file",invalid);}}
|
||||
public void save(List<FantasyArmorer> values)throws IOException{validate(values);Map<String,Object> root=new LinkedHashMap<>();root.put("schema-version",1);root.put("shops",values.stream().sorted(Comparator.comparing(FantasyArmorer::id)).map(value->{Map<String,Object> row=new LinkedHashMap<>();row.put("shop-id",value.id().toString());if(value.revision()!=null)row.put("registration-revision",value.revision().toString());row.put("name",value.name());row.put("world",value.world().toString());row.put("x",Double.toString(value.x()));row.put("y",Double.toString(value.y()));row.put("z",Double.toString(value.z()));row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));return row;}).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"armorers-",".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 Armorer persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
|
||||
private static void validate(List<FantasyArmorer> values){var ids=new HashSet<UUID>();if(values.stream().anyMatch(value->!ids.add(value.id())))throw new IllegalArgumentException("Duplicate Armorer identity");}private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);options.setCodePointLimit(1_048_576);return new Yaml(new SafeConstructor(options));}private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}private static String text(Map<?,?> map,String key){Object value=map.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException();}
|
||||
}
|
||||
@@ -39,7 +39,8 @@ public final class YamlTrainingDummyRepository {
|
||||
var row = map(value);
|
||||
result.add(new TrainingDummy(UUID.fromString(text(row.get("dummy-id"))), text(row.get("name")),
|
||||
UUID.fromString(text(row.get("world"))), decimal(row.get("x")), decimal(row.get("y")),
|
||||
decimal(row.get("z")), (float) decimal(row.get("yaw")), (float) decimal(row.get("pitch"))));
|
||||
decimal(row.get("z")), (float) decimal(row.get("yaw")), (float) decimal(row.get("pitch")),
|
||||
row.containsKey("registration-revision") ? UUID.fromString(text(row.get("registration-revision"))) : null));
|
||||
}
|
||||
validate(result); return List.copyOf(result);
|
||||
} catch (RuntimeException invalid) {
|
||||
@@ -53,6 +54,7 @@ public final class YamlTrainingDummyRepository {
|
||||
root.put("dummies", dummies.stream().sorted(Comparator.comparing(TrainingDummy::id)).map(value -> {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("dummy-id", value.id().toString()); row.put("name", value.name());
|
||||
if (value.revision() != null) row.put("registration-revision", value.revision().toString());
|
||||
row.put("world", value.world().toString()); row.put("x", Double.toString(value.x()));
|
||||
row.put("y", Double.toString(value.y())); row.put("z", Double.toString(value.z()));
|
||||
row.put("yaw", Float.toString(value.yaw())); row.put("pitch", Float.toString(value.pitch())); return row;
|
||||
|
||||
@@ -81,9 +81,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
|
||||
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
|
||||
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
|
||||
private games.dmg.fantasystick.events.ArmorEventBridge armorEvents;
|
||||
private games.dmg.fantasystick.persistence.HardMobXpSettings hardMobXpSettings;
|
||||
private games.dmg.fantasystick.events.HardMobXpEventBridge hardMobXpEvents;
|
||||
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
|
||||
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
|
||||
private games.dmg.fantasystick.events.CampEventBridge campEvents;
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
|
||||
private games.dmg.fantasystick.dungeon.DungeonCommands dungeonCommands;
|
||||
private games.dmg.fantasystick.party.CombatExperience combatExperience;
|
||||
@@ -106,11 +109,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.events.PartyEventBridge partyEvents;
|
||||
private games.dmg.fantasystick.training.TrainingDummyDirectory trainingDummies;
|
||||
private games.dmg.fantasystick.training.TrainingDummyController trainingDummyController;
|
||||
private games.dmg.fantasystick.events.TrainingEventBridge trainingEvents;
|
||||
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
|
||||
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
|
||||
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
|
||||
private games.dmg.fantasystick.shop.RareMerchantDirectory rareMerchants;
|
||||
private games.dmg.fantasystick.shop.RareMerchantController rareMerchantController;
|
||||
private games.dmg.fantasystick.events.RareEventBridge rareAuditEvents;
|
||||
private games.dmg.fantasystick.shop.RareMarket rareMarket;
|
||||
private games.dmg.fantasystick.persistence.RareSettings rareSettings;
|
||||
private games.dmg.fantasystick.persistence.DownedSettings downedSettings;
|
||||
@@ -360,6 +365,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
public void attachPartyEconomySettings(games.dmg.fantasystick.persistence.PartyEconomySettings settings){if(closed||partyEconomySettings!=null)throw new IllegalStateException("Party economy settings already attached or command closed");partyEconomySettings=Objects.requireNonNull(settings);}
|
||||
public void attachGearSettings(games.dmg.fantasystick.persistence.GearSettings settings){if(closed||gearSettings!=null)throw new IllegalStateException("Gear settings already attached or command closed");gearSettings=Objects.requireNonNull(settings);}
|
||||
public void attachCoinSettings(games.dmg.fantasystick.persistence.CoinSettings settings){if(closed||coinSettings!=null)throw new IllegalStateException("Coin settings already attached or command closed");coinSettings=Objects.requireNonNull(settings);}
|
||||
public void attachTrainingEvents(games.dmg.fantasystick.events.TrainingEventBridge events) {
|
||||
if (closed || trainingEvents != null) throw new IllegalStateException("Training events already attached or command closed");
|
||||
trainingEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
public void attachArmorEvents(games.dmg.fantasystick.events.ArmorEventBridge events) {
|
||||
if (closed || armorEvents != null) throw new IllegalStateException("Armor events already attached or closed");
|
||||
armorEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
public void attachHardMobXpEvents(games.dmg.fantasystick.events.HardMobXpEventBridge events) {
|
||||
if (closed || hardMobXpEvents != null) throw new IllegalStateException("Hard-mob XP events already attached or closed");
|
||||
hardMobXpEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
public void attachCoinEvents(games.dmg.fantasystick.events.CoinEventBridge events){if(closed||coinEvents!=null)throw new IllegalStateException("Coin events already attached or command closed");coinEvents=Objects.requireNonNull(events);}
|
||||
public void attachAbilityConfigEvents(games.dmg.fantasystick.events.AbilityConfigEventBridge events){if(closed||abilityConfigEvents!=null)throw new IllegalStateException("Ability config events already attached or command closed");abilityConfigEvents=Objects.requireNonNull(events);}
|
||||
public void attachHardMobXpSettings(games.dmg.fantasystick.persistence.HardMobXpSettings settings,
|
||||
@@ -373,6 +390,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
adventureQuestCommands = Objects.requireNonNull(commands); adventureQuestSettings = Objects.requireNonNull(settings);
|
||||
}
|
||||
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
|
||||
public void attachCampEvents(games.dmg.fantasystick.events.CampEventBridge value){campEvents=Objects.requireNonNull(value);}
|
||||
private games.dmg.fantasystick.events.ChampionEventBridge championEvents;
|
||||
public void attachChampionEvents(games.dmg.fantasystick.events.ChampionEventBridge value){championEvents=Objects.requireNonNull(value);}
|
||||
private games.dmg.fantasystick.events.DungeonAuditEventBridge dungeonEvents;
|
||||
public void attachDungeonEvents(games.dmg.fantasystick.events.DungeonAuditEventBridge value){dungeonEvents=Objects.requireNonNull(value);}
|
||||
public void attachDungeons(games.dmg.fantasystick.dungeon.DungeonCommands commands){if(closed||dungeonCommands!=null)throw new IllegalStateException("Dungeons already attached or command closed");dungeonCommands=Objects.requireNonNull(commands);}
|
||||
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
|
||||
public void attachSheetResourcePackEvents(games.dmg.fantasystick.events.SheetResourcePackEventBridge events){
|
||||
@@ -491,6 +513,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
public void attachArmorers(games.dmg.fantasystick.shop.FantasyArmorerDirectory directory,games.dmg.fantasystick.shop.FantasyArmorerController controller){if(closed||armorers!=null)throw new IllegalStateException("Armorers already attached or command closed");armorers=Objects.requireNonNull(directory);armorerController=Objects.requireNonNull(controller);}
|
||||
public void attachArmorMarket(games.dmg.fantasystick.shop.ArmorMarket market){if(closed||armorMarket!=null)throw new IllegalStateException("Armor market already attached or command closed");armorMarket=Objects.requireNonNull(market);}
|
||||
public void attachRareMarket(games.dmg.fantasystick.shop.RareMerchantDirectory directory,games.dmg.fantasystick.shop.RareMerchantController controller,games.dmg.fantasystick.shop.RareMarket market,games.dmg.fantasystick.persistence.RareSettings settings){if(closed||rareMerchants!=null)throw new IllegalStateException("Rare market already attached or command closed");rareMerchants=Objects.requireNonNull(directory);rareMerchantController=Objects.requireNonNull(controller);rareMarket=Objects.requireNonNull(market);rareSettings=Objects.requireNonNull(settings);}
|
||||
public void attachRareEvents(games.dmg.fantasystick.events.RareEventBridge events){if(rareAuditEvents!=null)throw new IllegalStateException("Rare events already attached");rareAuditEvents=Objects.requireNonNull(events);}
|
||||
public void attachDownedSettings(games.dmg.fantasystick.persistence.DownedSettings settings){if(closed||downedSettings!=null)throw new IllegalStateException("Downed settings already attached or command closed");downedSettings=Objects.requireNonNull(settings);}
|
||||
public void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(closed||downedEvents!=null)throw new IllegalStateException("Downed events already attached or command closed");downedEvents=Objects.requireNonNull(events);}
|
||||
|
||||
@@ -860,15 +883,79 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
else sender.sendMessage("Restocked all Fantasy Armorers with catalog "+catalog.revision()+"; the next automatic restock remains 00:00 UTC.");
|
||||
});}catch(RuntimeException rejected){report(null,"shop-restock-response",rejected);}});return;}
|
||||
if(args[1].equals("list")&&args.length==2){var values=armorers.all();if(values.isEmpty())sender.sendMessage("No Fantasy Armorers are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Fantasy Armorer":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}
|
||||
if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();java.util.concurrent.CompletableFuture<games.dmg.fantasystick.shop.FantasyArmorer> operation;try{operation=armorers.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch());}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer name is invalid.");return;}operation.whenComplete((saved,failure)->placeShopResponse(player,location,saved,failure));return;}
|
||||
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer UUID is invalid.");return;}armorers.remove(id).whenComplete((removed,failure)->removeShopResponse(sender,id,removed,failure));return;}
|
||||
if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();var auditActor=armorEvents==null?null:armorEvents.actor(sender,sessions,location);var auditOperation=UUID.randomUUID();java.util.concurrent.CompletableFuture<games.dmg.fantasystick.shop.FantasyArmorer> operation;try{operation=armorers.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch());}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer name is invalid.");return;}operation.whenComplete((saved,failure)->placeShopResponse(player,location,auditOperation,auditActor,saved,failure));return;}
|
||||
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer UUID is invalid.");return;}var auditActor=armorEvents==null?null:armorEvents.actor(sender,sessions,null);var auditOperation=UUID.randomUUID();armorers.remove(id).whenComplete((removed,failure)->removeShopResponse(sender,id,auditOperation,auditActor,removed,failure));return;}
|
||||
shopUsage(sender);
|
||||
}
|
||||
private static void shopUsage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin shop <place [name...]|remove <shop-uuid>|list|restock>");}
|
||||
private void placeShopResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.shop.FantasyArmorer saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))armorerController.reconcile(location.getWorld().getChunkAt(x,z));if(player.isOnline())player.sendMessage("Placed Fantasy Armorer "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"shop-entity",entityFailure);armorers.remove(saved.id()).whenComplete((ignored,rollback)->nextTick.accept(()->{armorerController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");}));}});}
|
||||
private void removeShopResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.shop.FantasyArmorer> removed,Throwable failure){if(failure!=null)report(null,"shop-remove",failure);if(closed)return;nextTick.accept(()->{if(failure!=null)sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Fantasy Armorer not found.");else try{armorerController.removeLoaded(id);sender.sendMessage("Removed Fantasy Armorer "+id+".");}catch(RuntimeException entityFailure){var value=removed.orElseThrow();report(null,"shop-entity-remove",entityFailure);armorers.restore(value).whenComplete((restored,rollback)->{if(rollback!=null)report(null,"shop-remove-rollback",rollback);if(closed)return;nextTick.accept(()->{if(rollback==null){var world=server.getWorld(value.world());if(world!=null&&world.isChunkLoaded(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4))armorerController.reconcile(world.getChunkAt(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4));sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");}else sender.sendMessage("Fantasy Armorer removal rollback failed; inspect diagnostics.");});});}});}
|
||||
private void placeShopResponse(org.bukkit.entity.Player player, org.bukkit.Location location, UUID operation,
|
||||
games.dmg.fantasystick.events.ArmorEventBridge.Actor actor,
|
||||
games.dmg.fantasystick.shop.FantasyArmorer saved, Throwable failure) {
|
||||
if (failure != null) report(actor == null ? null : actor.playerId(), "shop-place", failure);
|
||||
if (closed) return;
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure != null) {
|
||||
if (player.isOnline()) player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int x = location.getBlockX() >> 4, z = location.getBlockZ() >> 4;
|
||||
if (location.getWorld().isChunkLoaded(x, z)) armorerController.reconcile(location.getWorld().getChunkAt(x, z));
|
||||
if (armorEvents != null) {
|
||||
var observed = armorerController.observed(saved);
|
||||
String outcome = !observed.quality().equals("native") ? "unavailable"
|
||||
: !observed.ids().isEmpty() ? "materialized" : observed.chunkStatus().equals("unloaded") ? "unloaded" : "not_materialized";
|
||||
armorEvents.armorerChanged("armorer.placed", operation, saved, actor, observed, outcome);
|
||||
}
|
||||
if (player.isOnline()) player.sendMessage("Placed Fantasy Armorer " + saved.id() + ".");
|
||||
} catch (RuntimeException entityFailure) {
|
||||
report(player.getUniqueId(), "shop-entity", entityFailure);
|
||||
armorers.remove(saved.id()).whenComplete((ignored, rollback) -> nextTick.accept(() -> {
|
||||
armorerController.removeLoaded(saved.id());
|
||||
if (player.isOnline()) player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
private void removeShopResponse(CommandSender sender, UUID id, UUID operation,
|
||||
games.dmg.fantasystick.events.ArmorEventBridge.Actor actor,
|
||||
java.util.Optional<games.dmg.fantasystick.shop.FantasyArmorer> removed, Throwable failure) {
|
||||
if (failure != null) report(null, "shop-remove", failure);
|
||||
if (closed) return;
|
||||
nextTick.accept(() -> {
|
||||
if (failure != null) sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");
|
||||
else if (removed.isEmpty()) sender.sendMessage("Fantasy Armorer not found.");
|
||||
else try {
|
||||
var saved = removed.orElseThrow();
|
||||
var before = armorEvents == null ? null : armorerController.observed(saved);
|
||||
armorerController.removeLoaded(id);
|
||||
if (armorEvents != null) {
|
||||
var after = armorerController.observed(saved);
|
||||
String outcome = !before.quality().equals("native") || !after.quality().equals("native") ? "unavailable"
|
||||
: before.ids().isEmpty() ? "none_loaded" : before.ids().stream().anyMatch(after.ids()::contains) ? "not_removed" : "removed";
|
||||
armorEvents.armorerChanged("armorer.removed", operation, saved, actor, before, outcome);
|
||||
}
|
||||
sender.sendMessage("Removed Fantasy Armorer " + id + ".");
|
||||
} catch (RuntimeException entityFailure) {
|
||||
var value = removed.orElseThrow(); report(null, "shop-entity-remove", entityFailure);
|
||||
armorers.restore(value).whenComplete((restored, rollback) -> {
|
||||
if (rollback != null) report(null, "shop-remove-rollback", rollback);
|
||||
if (closed) return;
|
||||
nextTick.accept(() -> {
|
||||
if (rollback == null) {
|
||||
var world = server.getWorld(value.world());
|
||||
if (world != null && world.isChunkLoaded(((int) Math.floor(value.x())) >> 4, ((int) Math.floor(value.z())) >> 4))
|
||||
armorerController.reconcile(world.getChunkAt(((int) Math.floor(value.x())) >> 4, ((int) Math.floor(value.z())) >> 4));
|
||||
sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");
|
||||
} else sender.sendMessage("Fantasy Armorer removal rollback failed; inspect diagnostics.");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void rareShop(CommandSender sender,String[] args){if(rareMerchants==null||rareMerchantController==null||rareMarket==null||args.length<2){rareShopUsage(sender);return;}if(args[1].equals("restock")&&args.length==2){rareMarket.restock().whenComplete((catalog,failure)->{if(failure!=null)report(null,"rare-shop-restock",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Restocked Rare Items catalog "+catalog.revision()+"; next automatic restock remains 00:00 UTC.":"Rare Items restock failed; prior catalog is unchanged.");});});return;}if(args[1].equals("list")&&args.length==2){var values=rareMerchants.all();if(values.isEmpty())sender.sendMessage("No Rare Items merchants are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Rare Items":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();try{rareMerchants.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((saved,failure)->{if(failure!=null)report(player.getUniqueId(),"rare-shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed||!player.hasPermission(PERMISSION)||!player.isOnline())return;if(failure!=null){player.sendMessage("Rare Items placement failed; prior state is unchanged.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))rareMerchantController.reconcile(location.getWorld().getChunkAt(x,z));player.sendMessage("Placed Rare Items merchant "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"rare-shop-entity",entityFailure);rareMerchants.remove(saved.id());rareMerchantController.removeLoaded(saved.id());player.sendMessage("Rare Items placement failed; registration rollback was requested.");}});});}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant name is invalid.");}return;}if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);if(!id.toString().equals(args[2]))throw new IllegalArgumentException();}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant UUID is invalid.");return;}rareMerchants.remove(id).whenComplete((removed,failure)->{if(failure!=null)report(null,"rare-shop-remove",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare Items removal failed; registration is unchanged.");else if(removed.isEmpty())sender.sendMessage("Rare Items merchant not found.");else{rareMerchantController.removeLoaded(id);sender.sendMessage("Removed Rare Items merchant "+id+".");}});});return;}rareShopUsage(sender);}
|
||||
private void rareShop(CommandSender sender,String[] args){if(rareMerchants==null||rareMerchantController==null||rareMarket==null||args.length<2){rareShopUsage(sender);return;}if(args[1].equals("restock")&&args.length==2){rareMarket.restock().whenComplete((catalog,failure)->{if(failure!=null)report(null,"rare-shop-restock",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Restocked Rare Items catalog "+catalog.revision()+"; next automatic restock remains 00:00 UTC.":"Rare Items restock failed; prior catalog is unchanged.");});});return;}if(args[1].equals("list")&&args.length==2){var values=rareMerchants.all();if(values.isEmpty())sender.sendMessage("No Rare Items merchants are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Rare Items":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();try{rareMerchants.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((saved,failure)->{if(failure!=null)report(player.getUniqueId(),"rare-shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed||!player.hasPermission(PERMISSION)||!player.isOnline())return;if(failure!=null){player.sendMessage("Rare Items placement failed; prior state is unchanged.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))rareMerchantController.reconcile(location.getWorld().getChunkAt(x,z));if(rareAuditEvents!=null)rareAuditEvents.shopPlaced(saved,player.getUniqueId(),player.getName());player.sendMessage("Placed Rare Items merchant "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"rare-shop-entity",entityFailure);rareMerchants.remove(saved.id());rareMerchantController.removeLoaded(saved.id());player.sendMessage("Rare Items placement failed; registration rollback was requested.");}});});}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant name is invalid.");}return;}if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);if(!id.toString().equals(args[2]))throw new IllegalArgumentException();}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant UUID is invalid.");return;}rareMerchants.remove(id).whenComplete((removed,failure)->{if(failure!=null)report(null,"rare-shop-remove",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare Items removal failed; registration is unchanged.");else if(removed.isEmpty())sender.sendMessage("Rare Items merchant not found.");else{rareMerchantController.removeLoaded(id);if(rareAuditEvents!=null)rareAuditEvents.shopRemoved(UUID.randomUUID(),removed.orElseThrow(),sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null,sender instanceof org.bukkit.entity.Player namedPlayer?namedPlayer.getName():null);sender.sendMessage("Removed Rare Items merchant "+id+".");}});});return;}rareShopUsage(sender);}
|
||||
private static void rareShopUsage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin rare-shop <place [name...]|remove <shop-uuid>|list|restock>");}
|
||||
|
||||
private void trainingDummy(CommandSender sender, String[] args) {
|
||||
@@ -885,10 +972,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (args[1].equals("place") && sender instanceof org.bukkit.entity.Player player) {
|
||||
String name = args.length < 3 ? "" : String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length));
|
||||
var location = player.getLocation().clone();
|
||||
var owner = player.getUniqueId();
|
||||
var actor = trainingEvents == null ? null : trainingEvents.actor(sender, sessions, location);
|
||||
var operation = UUID.randomUUID();
|
||||
try {
|
||||
trainingDummies.place(name, location.getWorld().getUID(), location.getX(), location.getY(),
|
||||
location.getZ(), location.getYaw(), location.getPitch())
|
||||
.whenComplete((saved, failure) -> placeDummyResponse(player, location, saved, failure));
|
||||
.whenComplete((saved, failure) -> placeDummyResponse(player, owner, location, operation, actor, saved, failure));
|
||||
} catch (IllegalArgumentException invalid) { sender.sendMessage("Training dummy name is invalid."); }
|
||||
return;
|
||||
}
|
||||
@@ -896,7 +986,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
UUID id;
|
||||
try { id = UUID.fromString(args[2]); }
|
||||
catch (IllegalArgumentException invalid) { sender.sendMessage("Training dummy UUID is invalid."); return; }
|
||||
trainingDummies.remove(id).whenComplete((removed, failure) -> removeDummyResponse(sender, id, removed, failure));
|
||||
var actor = trainingEvents == null ? null : trainingEvents.actor(sender, sessions, null);
|
||||
var operation = UUID.randomUUID();
|
||||
trainingDummies.remove(id).whenComplete((removed, failure) -> removeDummyResponse(sender, id, operation, actor, removed, failure));
|
||||
return;
|
||||
}
|
||||
trainingDummyUsage(sender);
|
||||
@@ -906,9 +998,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
sender.sendMessage("Usage: /fstickadmin training-dummy <place [name...]|remove <dummy-uuid>|list>");
|
||||
}
|
||||
|
||||
private void placeDummyResponse(org.bukkit.entity.Player player, org.bukkit.Location location,
|
||||
private void placeDummyResponse(org.bukkit.entity.Player player, UUID owner, org.bukkit.Location location, UUID operation,
|
||||
games.dmg.fantasystick.events.TrainingEventBridge.Actor actor,
|
||||
games.dmg.fantasystick.training.TrainingDummy saved, Throwable failure) {
|
||||
if (failure != null) report(player.getUniqueId(), "training-dummy-place", failure);
|
||||
if (failure != null) report(owner, "training-dummy-place", failure);
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
@@ -918,7 +1011,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
if (!player.isOnline() || !player.hasPermission(PERMISSION)) {
|
||||
trainingDummies.remove(saved.id()).whenComplete((ignored, rollbackFailure) -> {
|
||||
if (rollbackFailure != null) report(player.getUniqueId(), "training-dummy-permission-rollback", rollbackFailure);
|
||||
if (rollbackFailure != null) report(owner, "training-dummy-permission-rollback", rollbackFailure);
|
||||
if (!closed) nextTick.accept(() -> {
|
||||
trainingDummyController.removeLoaded(saved.id());
|
||||
if (player.isOnline()) player.sendMessage("Training dummy placement was cancelled because permission changed.");
|
||||
@@ -931,21 +1024,28 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (location.getWorld().isChunkLoaded(chunkX, chunkZ)) {
|
||||
trainingDummyController.reconcile(location.getWorld().getChunkAt(chunkX, chunkZ));
|
||||
}
|
||||
if (trainingEvents != null) {
|
||||
var observed = trainingDummyController.observed(saved);
|
||||
String outcome = !observed.quality().equals("native") ? "unavailable"
|
||||
: !observed.ids().isEmpty() ? "materialized" : observed.chunkStatus().equals("unloaded") ? "unloaded" : "not_materialized";
|
||||
trainingEvents.registration("training_dummy.placed", operation, saved, actor, observed, outcome);
|
||||
}
|
||||
player.sendMessage("Placed training dummy " + saved.id() + ".");
|
||||
} catch (RuntimeException entityFailure) {
|
||||
report(player.getUniqueId(), "training-dummy-entity", entityFailure);
|
||||
report(owner, "training-dummy-entity", entityFailure);
|
||||
trainingDummies.remove(saved.id()).whenComplete((ignored, rollbackFailure) -> {
|
||||
if (rollbackFailure != null) report(player.getUniqueId(), "training-dummy-rollback", rollbackFailure);
|
||||
if (rollbackFailure != null) report(owner, "training-dummy-rollback", rollbackFailure);
|
||||
if (!closed) nextTick.accept(() -> {
|
||||
trainingDummyController.removeLoaded(saved.id());
|
||||
if (player.isOnline()) player.sendMessage("Training dummy placement failed; no record or entity changed.");
|
||||
});
|
||||
});
|
||||
}
|
||||
}); } catch (RuntimeException rejected) { report(player.getUniqueId(), "training-dummy-response", rejected); }
|
||||
}); } catch (RuntimeException rejected) { report(owner, "training-dummy-response", rejected); }
|
||||
}
|
||||
|
||||
private void removeDummyResponse(CommandSender sender, UUID id,
|
||||
private void removeDummyResponse(CommandSender sender, UUID id, UUID operation,
|
||||
games.dmg.fantasystick.events.TrainingEventBridge.Actor actor,
|
||||
java.util.Optional<games.dmg.fantasystick.training.TrainingDummy> removed, Throwable failure) {
|
||||
if (failure != null) report(null, "training-dummy-remove", failure);
|
||||
if (closed) return;
|
||||
@@ -958,7 +1058,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
restoreDummy(sender, removed.orElseThrow(), "Training dummy removal was cancelled because permission changed.");
|
||||
return;
|
||||
}
|
||||
try { trainingDummyController.removeLoaded(id); sender.sendMessage("Removed training dummy " + id + "."); }
|
||||
try {
|
||||
var saved = removed.orElseThrow();
|
||||
var before = trainingEvents == null ? null : trainingDummyController.observed(saved);
|
||||
trainingDummyController.removeLoaded(id);
|
||||
if (trainingEvents != null) {
|
||||
var after = trainingDummyController.observed(saved);
|
||||
String outcome = !before.quality().equals("native") || !after.quality().equals("native") ? "unavailable"
|
||||
: before.ids().isEmpty() ? "none_loaded" : before.ids().stream().anyMatch(after.ids()::contains) ? "not_removed" : "removed";
|
||||
trainingEvents.registration("training_dummy.removed", operation, saved, actor, before, outcome);
|
||||
}
|
||||
sender.sendMessage("Removed training dummy " + id + ".");
|
||||
}
|
||||
catch (RuntimeException entityFailure) {
|
||||
report(null, "training-dummy-entity", entityFailure);
|
||||
restoreDummy(sender, removed.orElseThrow(), "Training dummy removal failed; record and entity are unchanged.");
|
||||
@@ -1280,9 +1391,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
private void quickerRegenerationIIConfiguration(CommandSender sender, String[] args) {
|
||||
String key = games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY;
|
||||
String before = quickerRegenerationIISettings.value();
|
||||
String actorKind = sender instanceof org.bukkit.entity.Player ? "player"
|
||||
: sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console";
|
||||
UUID actorPlayer = sender instanceof org.bukkit.entity.Player player ? player.getUniqueId() : null;
|
||||
String actorName = sender instanceof org.bukkit.entity.Player ? sender.getName() : null;
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY + "="
|
||||
+ quickerRegenerationIISettings.value() + " (applies to future recovery pulses only)"); return;
|
||||
if (passiveEvents != null) passiveEvents.configRead(actorKind, actorPlayer, actorName, key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future recovery pulses only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.Update> operation;
|
||||
try { operation = quickerRegenerationIISettings.set(args[2]); }
|
||||
@@ -1295,7 +1412,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return; boolean applied = false;
|
||||
if (failure == null) {
|
||||
applied = quickerRegenerationIISettings.publish(saved);
|
||||
if (applied) { quickerRegenerationII.configure(saved.settings()); passiveRegistry.replace(quickerRegenerationII.definition()); }
|
||||
if (applied) {
|
||||
quickerRegenerationII.configure(saved.settings()); passiveRegistry.replace(quickerRegenerationII.definition());
|
||||
if (passiveEvents != null) passiveEvents.configChanged(actorKind, actorPlayer, actorName, key, before, saved.value());
|
||||
}
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
@@ -1309,9 +1429,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
private void healthStealConfiguration(CommandSender sender, String[] args) {
|
||||
String key = games.dmg.fantasystick.persistence.HealthStealSettings.KEY;
|
||||
String before = healthStealSettings.value();
|
||||
String actorKind = sender instanceof org.bukkit.entity.Player ? "player"
|
||||
: sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console";
|
||||
UUID actorPlayer = sender instanceof org.bukkit.entity.Player player ? player.getUniqueId() : null;
|
||||
String actorName = sender instanceof org.bukkit.entity.Player ? sender.getName() : null;
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.HealthStealSettings.KEY + "="
|
||||
+ healthStealSettings.value() + " (applies to later qualifying final blows only)"); return;
|
||||
if (passiveEvents != null) passiveEvents.configRead(actorKind, actorPlayer, actorName, key, before, "future_qualifying_defeats");
|
||||
sender.sendMessage(key + "=" + before + " (applies to later qualifying final blows only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HealthStealSettings.Update> operation;
|
||||
try { operation = healthStealSettings.set(args[2]); }
|
||||
@@ -1324,7 +1450,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return; boolean applied = false;
|
||||
if (failure == null) {
|
||||
applied = healthStealSettings.publish(saved);
|
||||
if (applied) { healthSteal.configure(saved.settings()); passiveRegistry.replace(healthSteal.definition()); }
|
||||
if (applied) {
|
||||
healthSteal.configure(saved.settings()); passiveRegistry.replace(healthSteal.definition());
|
||||
if (passiveEvents != null) passiveEvents.configChanged(actorKind, actorPlayer, actorName, key, before, saved.value(), "future_qualifying_defeats");
|
||||
}
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
@@ -1898,16 +2027,59 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void partyEconomyConfiguration(CommandSender sender,String[] args){String key=args[1],before=partyEconomySettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(partyEvents!=null)partyEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future party transactions only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartyEconomySettings.Update> operation;try{operation=partyEconomySettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party price is invalid; enter a whole number from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&partyEconomySettings.publish(saved);if(applied&&partyEvents!=null)partyEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party price save failed; the effective price is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+partyEconomySettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future party transactions.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void gearConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+gearSettings.value(key)+" (applies to the next armor restock only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GearSettings.Update> operation;try{operation=gearSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Armor market setting is invalid; variance is 0–100, prices are nonnegative, and positive stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&gearSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Armor market setting save failed; effective settings and the current catalog are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+gearSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for the next automatic or administrative restock; the current catalog is unchanged.");});});}
|
||||
private void rareConfiguration(CommandSender sender,String[] args){String key=args[1],before=rareSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;boolean phoenix=key.contains("phoenix-down");if(args.length==2){if(phoenix&&downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+(key.endsWith("-healing-percent")||key.equals(games.dmg.fantasystick.persistence.RareSettings.PHOENIX_PROTECTION)?" (applies to future accepted uses)":" (applies to the next rare restock only)"));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RareSettings.Update> operation;try{operation=rareSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Rare setting is invalid; percentages are 0–100, values are nonnegative, and stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&rareSettings.publish(saved);if(applied&&phoenix&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+rareSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; current catalog and accepted uses are unchanged.");});});}
|
||||
private void gearConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
String value = gearSettings.value(key);
|
||||
if (armorEvents != null) armorEvents.configRead(armorEvents.actor(sender, sessions, null), key, value);
|
||||
sender.sendMessage(key + "=" + value + " (applies to the next armor restock only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GearSettings.Update> operation;
|
||||
try { operation = gearSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Armor market setting is invalid; variance is 0–100, prices are nonnegative, and positive stock minima cannot exceed maxima.");
|
||||
return;
|
||||
}
|
||||
var actor = armorEvents == null ? null : armorEvents.actor(sender, sessions, null);
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
nextTick.accept(() -> {
|
||||
String before = gearSettings.value(key);
|
||||
boolean applied = failure == null && gearSettings.publish(saved);
|
||||
if (applied && armorEvents != null) armorEvents.configChanged(actor, key, before, saved.value());
|
||||
if (!sender.hasPermission(PERMISSION) || sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) sender.sendMessage("Armor market setting save failed; effective settings and the current catalog are unchanged.");
|
||||
else if (!applied) sender.sendMessage("Saved request was superseded; effective " + key + "=" + gearSettings.value(key) + ".");
|
||||
else sender.sendMessage("Saved " + key + "=" + saved.value() + " for the next automatic or administrative restock; the current catalog is unchanged.");
|
||||
});
|
||||
});
|
||||
}
|
||||
private static boolean potionConfig(String key){
|
||||
if(key.equals("rare-price-variance-percent"))return true;
|
||||
for(var product:games.dmg.fantasystick.shop.RareProduct.values())if(product.heals()){
|
||||
var id=product.id();
|
||||
if(key.equals("rare-price-"+id)||key.equals("rare-stock-"+id+"-min")
|
||||
||key.equals("rare-stock-"+id+"-max")||key.equals("rare-"+id+"-healing-percent"))return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
private void rareConfiguration(CommandSender sender,String[] args){String key=args[1],before=rareSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;boolean phoenix=key.contains("phoenix-down");if(args.length==2){if(phoenix&&downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);else if((potionConfig(key)||key.endsWith("-reskill")||key.startsWith("rare-stock-reskill-")||key.endsWith("-magic-core")||key.startsWith("rare-stock-magic-core-"))&&rareAuditEvents!=null)rareAuditEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+(key.endsWith("-healing-percent")||key.equals(games.dmg.fantasystick.persistence.RareSettings.PHOENIX_PROTECTION)?" (applies to future accepted uses)":" (applies to the next rare restock only)"));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RareSettings.Update> operation;try{operation=rareSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Rare setting is invalid; percentages are 0–100, values are nonnegative, and stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&rareSettings.publish(saved);if(applied&&phoenix&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());else if(applied&&(potionConfig(key)||key.endsWith("-reskill")||key.startsWith("rare-stock-reskill-")||key.endsWith("-magic-core")||key.startsWith("rare-stock-magic-core-"))&&rareAuditEvents!=null)rareAuditEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+rareSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; current catalog and accepted uses are unchanged.");});});}
|
||||
private void downedConfiguration(CommandSender sender,String[] args){String key=args[1],before=downedSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future downed outcomes only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.DownedSettings.Update> operation;try{operation=downedSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Downed setting is invalid; timeout must be positive and protection may be zero, both at most 365 days.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&downedSettings.publish(saved);if(applied&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Downed setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+downedSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; existing deadlines are unchanged.");});});}
|
||||
|
||||
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1],before=coinSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(coinEvents!=null)coinEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&coinSettings.publish(saved);if(applied&&coinEvents!=null)coinEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1],before=coinSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(coinEvents!=null)coinEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;String publishedBefore=coinSettings.value(key);boolean applied=failure==null&&coinSettings.publish(saved);if(applied&&coinEvents!=null)coinEvents.configChanged(actorKind,actorPlayer,actorName,key,publishedBefore,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void hardMobXpConfiguration(CommandSender sender, String[] args) {
|
||||
String key = games.dmg.fantasystick.persistence.HardMobXpSettings.KEY;
|
||||
if (args.length == 2) { sender.sendMessage(key + "=" + hardMobXpSettings.value()
|
||||
+ " (applies to future uncaptured Witch defeats only)"); return; }
|
||||
if (args.length == 2) {
|
||||
String value = hardMobXpSettings.value();
|
||||
if (hardMobXpEvents != null) hardMobXpEvents.read(hardMobXpEvents.actor(sender, sessions), value);
|
||||
sender.sendMessage(key + "=" + value + " (applies to future uncaptured Witch defeats only)"); return;
|
||||
}
|
||||
var audit = hardMobXpEvents;
|
||||
var actor = audit == null ? null : audit.actor(sender, sessions);
|
||||
var operationId = UUID.randomUUID();
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HardMobXpSettings.Update> operation;
|
||||
try { operation = hardMobXpSettings.set(args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
@@ -1918,8 +2090,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
String before = hardMobXpSettings.value();
|
||||
boolean applied = failure == null && hardMobXpSettings.publish(saved);
|
||||
if (applied) combatExperience.configure(combatExperience.rules().withHardPool(saved.pool()));
|
||||
if (applied) {
|
||||
combatExperience.configure(combatExperience.rules().withHardPool(saved.pool()));
|
||||
if (audit != null && !before.equals(saved.value())) audit.changed(actor, operationId, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) sender.sendMessage("Hard-mob XP save failed; the effective pool is unchanged.");
|
||||
@@ -1937,9 +2113,17 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
||key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.FISHING_CATCH_XP_MAX);
|
||||
var audit=fishingKey?fishingEvents:null;
|
||||
var actor=audit==null?null:audit.actor(sender);
|
||||
var campAudit=games.dmg.fantasystick.events.CampEventBridge.ownsSetting(key)?campEvents:null;
|
||||
var campActor=campAudit==null?null:campAudit.actor(sender,null);
|
||||
var championAudit=games.dmg.fantasystick.events.ChampionEventBridge.ownsSetting(key)?championEvents:null;
|
||||
var championActor=championAudit==null||campEvents==null?null:campEvents.actor(sender,null);
|
||||
var dungeonAudit=key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.DUNGEON_ROOM_LINK_DISTANCE)?dungeonEvents:null;
|
||||
var dungeonActor=dungeonAudit==null?null:dungeonAudit.actor(sender);
|
||||
if(args.length==2){
|
||||
var effective=adventureQuestSettings.value(key);
|
||||
if(audit!=null)audit.configRead(actor,key,effective,games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
|
||||
if(campAudit!=null)campAudit.configRead(campActor,key,effective,games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
|
||||
if(championAudit!=null)championAudit.configRead(championActor,key,effective,games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
|
||||
sender.sendMessage(key+"="+effective+" (applies to the owning later catalog, acceptance or encounter attempt)");return;
|
||||
}
|
||||
var operationId=UUID.randomUUID();
|
||||
@@ -1952,6 +2136,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
var effective=adventureQuestSettings.value(key);
|
||||
if(applied&&audit!=null&&!before.equals(effective))audit.configChanged(actor,operationId,key,before,effective,
|
||||
games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
|
||||
if(applied&&campAudit!=null&&!before.equals(effective))campAudit.configChanged(campActor,operationId,key,before,effective,
|
||||
games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
|
||||
if(applied&&championAudit!=null&&!before.equals(effective))championAudit.configChanged(championActor,operationId,key,before,effective,
|
||||
games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
|
||||
if(applied&&dungeonAudit!=null&&!before.equals(effective))try{dungeonAudit.configChanged(dungeonActor,operationId,key,before,effective,games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));}catch(RuntimeException ignored){}
|
||||
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
|
||||
if(failure!=null)sender.sendMessage("Adventure quest configuration save failed; effective values are unchanged.");
|
||||
else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+effective+".");
|
||||
|
||||
@@ -11,8 +11,16 @@ import java.util.function.Supplier;
|
||||
/** Serialized durable authority for the one shared daily armor catalog. */
|
||||
public final class ArmorMarket implements AutoCloseable {
|
||||
public enum PurchaseStatus { APPLIED, SOLD_OUT, STALE, INSUFFICIENT, NOT_UPGRADE, UNAVAILABLE, FAILED }
|
||||
public record PurchaseOutcome(PurchaseStatus status, Optional<CharacterRecord> record, ArmorCatalog catalog) {
|
||||
public record PurchaseProof(UUID operation, java.math.BigInteger price, int stockBefore, int stockAfter) {}
|
||||
public record PurchaseOutcome(PurchaseStatus status, Optional<CharacterRecord> record, ArmorCatalog catalog, PurchaseProof proof) {
|
||||
public PurchaseOutcome { Objects.requireNonNull(status); Objects.requireNonNull(record); Objects.requireNonNull(catalog); }
|
||||
public PurchaseOutcome(PurchaseStatus status, Optional<CharacterRecord> record, ArmorCatalog catalog) {
|
||||
this(status, record, catalog, null);
|
||||
}
|
||||
}
|
||||
private static PurchaseProof proof(ArmorPurchaseOperationStore.Plan plan, ArmorCatalog catalog) {
|
||||
int after = catalog.offer(plan.product()).remaining();
|
||||
return new PurchaseProof(plan.id(), plan.price(), after + 1, after);
|
||||
}
|
||||
|
||||
private final ArmorCatalogRepository catalogs;
|
||||
@@ -26,6 +34,8 @@ public final class ArmorMarket implements AutoCloseable {
|
||||
private volatile ArmorCatalog snapshot;
|
||||
private volatile boolean initialized;
|
||||
private volatile Consumer<ArmorCatalog> listener=ignored->{};
|
||||
private games.dmg.fantasystick.events.ArmorEventBridge auditEvents;
|
||||
private ArmorCatalog newlyPublishedCatalog;
|
||||
private boolean closed,operationsClosing;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
@@ -40,6 +50,15 @@ public final class ArmorMarket implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void attachListener(Consumer<ArmorCatalog> listener){this.listener=Objects.requireNonNull(listener);}
|
||||
public synchronized void attachEvents(games.dmg.fantasystick.events.ArmorEventBridge events) {
|
||||
if (closed || auditEvents != null) throw new IllegalStateException("Armor market events already attached or closed");
|
||||
auditEvents = Objects.requireNonNull(events);
|
||||
if (newlyPublishedCatalog != null) auditEvents.catalogRefreshed(newlyPublishedCatalog);
|
||||
}
|
||||
private synchronized void refreshed(ArmorCatalog catalog) {
|
||||
newlyPublishedCatalog = catalog;
|
||||
if (auditEvents != null) auditEvents.catalogRefreshed(catalog);
|
||||
}
|
||||
public Optional<ArmorCatalog> snapshot(){return Optional.ofNullable(snapshot);}
|
||||
public Instant now(){return clock.instant();}
|
||||
public CompletionStage<Void> ready(){return readiness;}
|
||||
@@ -114,7 +133,8 @@ public final class ArmorMarket implements AutoCloseable {
|
||||
var pruned=catalog.prune(plan.id());persist(pruned,false);
|
||||
var record=characters.find(plan.character()).join();
|
||||
return new PurchaseOutcome(transaction.state()==ArmorCatalog.TransactionState.CONSUMED
|
||||
?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,record,pruned);
|
||||
?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,record,pruned,
|
||||
transaction.state()==ArmorCatalog.TransactionState.CONSUMED ? proof(plan,pruned) : null);
|
||||
}
|
||||
CharacterStorage.GearPurchase purchase;
|
||||
try {
|
||||
@@ -133,7 +153,7 @@ public final class ArmorMarket implements AutoCloseable {
|
||||
case STALE->PurchaseStatus.STALE;
|
||||
case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;
|
||||
};
|
||||
return new PurchaseOutcome(status,purchase.record(),catalog);
|
||||
return new PurchaseOutcome(status,purchase.record(),catalog,consumed ? proof(plan,catalog) : null);
|
||||
}
|
||||
|
||||
private ArmorCatalog ensureCurrent(ArmorCatalog.RefreshReason reason)throws IOException {
|
||||
@@ -148,7 +168,10 @@ public final class ArmorMarket implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void persist(ArmorCatalog catalog,boolean notify)throws IOException {
|
||||
catalogs.save(catalog);snapshot=catalog;if(notify)publish(catalog);
|
||||
var before = snapshot;
|
||||
catalogs.save(catalog);snapshot=catalog;
|
||||
if (notify && (before == null || !before.revision().equals(catalog.revision()))) refreshed(catalog);
|
||||
if(notify)publish(catalog);
|
||||
}
|
||||
|
||||
private void publish(ArmorCatalog catalog){if(!initialized)return;try{listener.accept(catalog);}catch(RuntimeException ignored){}}
|
||||
|
||||
@@ -19,6 +19,11 @@ public final class ArmorShop {
|
||||
private final ArmorMarket market;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Throwable> failures;
|
||||
private games.dmg.fantasystick.events.ArmorEventBridge events;
|
||||
public void attachEvents(games.dmg.fantasystick.events.ArmorEventBridge events) {
|
||||
if (this.events != null) throw new IllegalStateException("Armor shop events already attached");
|
||||
this.events = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
public ArmorShop(FantasyArmorerDirectory shops,EquipmentSessions sessions,ArmorMarket market,
|
||||
Consumer<Runnable> nextTick,Consumer<Throwable> failures){
|
||||
@@ -52,6 +57,7 @@ public final class ArmorShop {
|
||||
var context=context(player,shopId);
|
||||
if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}
|
||||
var captured=context.orElseThrow();
|
||||
var auditActor=events==null?null:events.actor(player,sessions,null);
|
||||
market.purchase(captured.record().id(),captured.record().revision(),catalogRevision,product).whenComplete((purchase,failure)->{
|
||||
if(failure!=null){report(failure);publishHeld(held,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}
|
||||
publishHeld(held,()->{
|
||||
@@ -64,8 +70,13 @@ public final class ArmorShop {
|
||||
case NOT_UPGRADE->Status.NOT_UPGRADE;case STALE->Status.STALE;
|
||||
case UNAVAILABLE,FAILED->Status.FAILED;
|
||||
};
|
||||
if(purchase.status()==ArmorMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent())
|
||||
sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());
|
||||
if(purchase.status()==ArmorMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent()) {
|
||||
var saved=purchase.record().orElseThrow();
|
||||
boolean acknowledged=sessions.acknowledgeProgress(captured.active(),captured.record().revision(),saved);
|
||||
if(acknowledged&&events!=null&&purchase.proof()!=null)
|
||||
events.purchased(purchase.proof(),purchase.catalog().revision(),captured.shop(),product,
|
||||
auditActor,captured.record(),saved);
|
||||
}
|
||||
result.accept(new Outcome(status,purchase.record()));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
import java.util.*;
|
||||
/** Durable desired location for one administrator-managed Fantasy Armorer. */
|
||||
public record FantasyArmorer(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch){public FantasyArmorer{Objects.requireNonNull(id);Objects.requireNonNull(name);Objects.requireNonNull(world);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(codePoint->{int type=Character.getType(codePoint);return type==Character.CONTROL||type==Character.FORMAT||type==Character.SURROGATE;})||!Double.isFinite(x)||!Double.isFinite(y)||!Double.isFinite(z)||!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid Fantasy Armorer");}}
|
||||
public record FantasyArmorer(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch,UUID revision){public FantasyArmorer(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch){this(id,name,world,x,y,z,yaw,pitch,null);}public FantasyArmorer{Objects.requireNonNull(id);Objects.requireNonNull(name);Objects.requireNonNull(world);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(codePoint->{int type=Character.getType(codePoint);return type==Character.CONTROL||type==Character.FORMAT||type==Character.SURROGATE;})||!Double.isFinite(x)||!Double.isFinite(y)||!Double.isFinite(z)||!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid Fantasy Armorer");}}
|
||||
@@ -5,6 +5,15 @@ public final class FantasyArmorerController implements Listener,AutoCloseable{
|
||||
private final Plugin plugin;private final FantasyArmorerDirectory directory;private final BiConsumer<Player,UUID> interactions;private final NamespacedKey idKey,typeKey;private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private boolean closed;public FantasyArmorerController(Plugin plugin,FantasyArmorerDirectory directory,BiConsumer<Player,UUID> interactions){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.interactions=Objects.requireNonNull(interactions);idKey=new NamespacedKey(plugin,"fantasy_armorer_id");typeKey=new NamespacedKey(plugin,"managed_npc_type");}
|
||||
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 "fantasy_armorer".equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
|
||||
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,FantasyArmorer>();for(var value:directory.all())if(value.world().equals(chunk.getWorld().getUID())&§ion(value.x())==chunk.getX()&§ion(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 target:desired.values())if(!retained.contains(target.id()))spawn(target);}
|
||||
public record Observed(String chunkStatus,List<UUID> ids,String quality){public Observed{ids=List.copyOf(ids);}}
|
||||
public Observed observed(FantasyArmorer value){
|
||||
try{var world=plugin.getServer().getWorld(value.world());if(world==null)return new Observed("unavailable",List.of(),"unavailable");
|
||||
String chunk=world.isChunkLoaded(section(value.x()),section(value.z()))?"loaded":"unloaded";
|
||||
var entities=world.getEntities();if(entities==null)return new Observed(chunk,List.of(),"unavailable");
|
||||
var ids=new ArrayList<UUID>();for(var entity:entities)if(entity instanceof Villager&&tagged(entity)&&value.id().equals(id(entity)))ids.add(entity.getUniqueId());
|
||||
ids.sort(Comparator.comparing(UUID::toString));return new Observed(chunk,ids,"native");
|
||||
}catch(RuntimeException unavailable){return new Observed("unavailable",List.of(),"unavailable");}
|
||||
}
|
||||
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(FantasyArmorer value){var world=plugin.getServer().getWorld(value.world());if(world==null)return;int x=section(value.x()),z=section(value.z());if(world.isChunkLoaded(x,z))world.spawn(location(value),Villager.class,villager->configure(villager,value));}private void configure(Villager villager,FantasyArmorer value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,"fantasy_armorer");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.name().isBlank()?"Fantasy Armorer":value.name()));villager.setCustomNameVisible(true);}private Location location(FantasyArmorer value){return new Location(plugin.getServer().getWorld(value.world()),value.x(),value.y(),value.z(),value.yaw(),value.pitch());}private UUID id(Entity entity){String value=entity.getPersistentDataContainer().get(idKey,PersistentDataType.STRING);try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}private static int section(double value){return((int)Math.floor(value))>>4;}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(org.bukkit.event.player.PlayerInteractEntityEvent event){if(!tagged(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=org.bukkit.inventory.EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked());if(id!=null&&directory.find(id).isPresent())interactions.accept(event.getPlayer(),id);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(org.bukkit.event.entity.EntityDamageEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void combust(org.bukkit.event.entity.EntityCombustEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void transform(org.bukkit.event.entity.EntityTransformEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void breed(org.bukkit.event.entity.EntityBreedEvent event){if(tagged(event.getMother())||tagged(event.getFather()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event){if(tagged(event.getEntered()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(org.bukkit.event.entity.PlayerLeashEntityEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(org.bukkit.event.entity.EntityTeleportEvent event){if(tagged(event.getEntity())&&!relocating.contains(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.MONITOR)public void chunk(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
|
||||
|
||||
@@ -3,7 +3,7 @@ import games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository;import ja
|
||||
/** Serialized durable Fantasy Armorer desired state. */
|
||||
public final class FantasyArmorerDirectory implements AutoCloseable{
|
||||
private final YamlFantasyArmorerRepository repository;private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-armorer-persistence"));private volatile List<FantasyArmorer> snapshot;private final Map<UUID,Integer> leases=new HashMap<>();private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();public FantasyArmorerDirectory(YamlFantasyArmorerRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}public List<FantasyArmorer> all(){return snapshot;}public Optional<FantasyArmorer> find(UUID id){return snapshot.stream().filter(value->value.id().equals(id)).findFirst();}
|
||||
public CompletableFuture<FantasyArmorer> place(String name,UUID world,double x,double y,double z,float yaw,float pitch){return submit(()->{synchronized(this){var value=new FantasyArmorer(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}public CompletableFuture<Optional<FantasyArmorer>> remove(UUID id){return submit(()->{synchronized(this){var found=find(id);if(found.isEmpty())return Optional.empty();if(leases.getOrDefault(id,0)>0)throw new IOException("Fantasy Armorer has an active purchase");var next=snapshot.stream().filter(value->!value.id().equals(id)).toList();repository.save(next);snapshot=next;return found;}});}public CompletableFuture<FantasyArmorer> restore(FantasyArmorer value){return submit(()->{synchronized(this){var found=find(value.id());if(found.isPresent()){if(found.orElseThrow().equals(value))return value;throw new IOException("Armorer identity collision");}var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}
|
||||
public CompletableFuture<FantasyArmorer> place(String name,UUID world,double x,double y,double z,float yaw,float pitch){return submit(()->{synchronized(this){var value=new FantasyArmorer(UUID.randomUUID(),name,world,x,y,z,yaw,pitch,UUID.randomUUID());var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}public CompletableFuture<Optional<FantasyArmorer>> remove(UUID id){return submit(()->{synchronized(this){var found=find(id);if(found.isEmpty())return Optional.empty();if(leases.getOrDefault(id,0)>0)throw new IOException("Fantasy Armorer has an active purchase");var next=snapshot.stream().filter(value->!value.id().equals(id)).toList();repository.save(next);snapshot=next;return found;}});}public CompletableFuture<FantasyArmorer> restore(FantasyArmorer value){return submit(()->{synchronized(this){var found=find(value.id());if(found.isPresent()){if(found.orElseThrow().equals(value))return value;throw new IOException("Armorer identity collision");}var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}
|
||||
public synchronized Optional<Lease> acquire(UUID id){if(closed||find(id).isEmpty())return Optional.empty();leases.merge(id,1,Integer::sum);return Optional.of(new Lease(id));}public final class Lease implements AutoCloseable{private final UUID id;private boolean released;private Lease(UUID id){this.id=id;}public FantasyArmorer shop(){return find(id).orElseThrow();}@Override public void close(){synchronized(FantasyArmorerDirectory.this){if(released)return;released=true;int remaining=leases.getOrDefault(id,0)-1;if(remaining<=0)leases.remove(id);else leases.put(id,remaining);}}}
|
||||
private <T>CompletableFuture<T> submit(Callable<T> operation){var result=new CompletableFuture<T>();synchronized(this){if(closed){result.completeExceptionally(new IOException("Armorer directory closed"));return result;}pending++;}worker.execute(()->{try{result.complete(operation.call());}catch(Throwable failure){result.completeExceptionally(failure);}finally{finished();}});return result;}private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -21,6 +21,12 @@ public final class RareMagicCoreService {
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Throwable> failures;
|
||||
private games.dmg.fantasystick.events.RareEventBridge events;
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.RareEventBridge events){
|
||||
if(this.events!=null)throw new IllegalStateException("Magic Core events already attached");
|
||||
this.events=Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
public RareMagicCoreService(RareInventoryDirectory inventory, CharacterStorage characters,
|
||||
EquipmentSessions sessions, Consumer<Runnable> nextTick,
|
||||
@@ -38,22 +44,33 @@ public final class RareMagicCoreService {
|
||||
BigInteger quantity=quantity(captured.record().id());
|
||||
if(quantity.signum()<=0){result.accept(new Outcome(Status.NOT_AVAILABLE,Optional.of(captured.record())));return;}
|
||||
UUID operation=UUID.randomUUID();
|
||||
var actor=events==null?null:safeActor(player,captured);
|
||||
inventory.reserveUse(captured.record().id(),RareProduct.MAGIC_CORE,operation,quantity).whenComplete((reservation,reserveFailure)->{
|
||||
if(reserveFailure!=null){fail(reserveFailure,result);return;}
|
||||
if(reservation.status()!=RareInventoryDirectory.UseStatus.RESERVED){publish(()->result.accept(new Outcome(reservation.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE,Optional.empty())));return;}
|
||||
characters.consumeMagicCore(captured.record().id(),captured.record().revision(),operation).whenComplete((credit,creditFailure)->complete(player,captured,operation,credit,creditFailure,result));
|
||||
characters.consumeMagicCore(captured.record().id(),captured.record().revision(),operation).whenComplete((credit,creditFailure)->complete(player,captured,operation,quantity,actor,credit,creditFailure,result));
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve interrupted credits from authoritative character proof without publishing a second point. */
|
||||
public void install(){for(var use:inventory.unresolvedUses()){if(!use.product().equals(RareProduct.MAGIC_CORE.id()))continue;characters.find(use.character()).whenComplete((record,failure)->{if(failure!=null){report(failure);return;}boolean applied=record.isPresent()&&record.orElseThrow().rewardReceipts().contains(use.operation());var work=applied?inventory.commitUse(use.operation()):inventory.refundUse(use.operation());work.exceptionally(problem->{report(problem);return RareInventoryDirectory.UseStatus.RESERVED;});});}}
|
||||
|
||||
private void complete(Player player,Context captured,UUID operation,CharacterStorage.MagicCore credit,Throwable failure,Consumer<Outcome> result){
|
||||
private void complete(Player player,Context captured,UUID operation,BigInteger quantity,
|
||||
games.dmg.fantasystick.events.EventInput.Context actor,CharacterStorage.MagicCore credit,Throwable failure,Consumer<Outcome> result){
|
||||
if(failure!=null||credit==null){if(failure!=null)report(failure);refund(operation,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}
|
||||
if(credit.status()!=CharacterStorage.MagicCoreStatus.APPLIED&&credit.status()!=CharacterStorage.MagicCoreStatus.DUPLICATE){var status=credit.status()==CharacterStorage.MagicCoreStatus.UNAVAILABLE?Status.NOT_AVAILABLE:Status.STALE;refund(operation,()->result.accept(new Outcome(status,credit.record())));return;}
|
||||
publish(()->{boolean acknowledged=false;try{var current=context(player);acknowledged=current.isPresent()&¤t.orElseThrow().active().session().equals(captured.active().session())&&(credit.status()==CharacterStorage.MagicCoreStatus.DUPLICATE||sessions.acknowledgeProgress(captured.active(),captured.record().revision(),credit.record().orElseThrow()));}catch(RuntimeException uncertain){report(uncertain);}boolean currentSession=acknowledged;inventory.commitUse(operation).whenComplete((ignored,commitFailure)->{if(commitFailure!=null){report(commitFailure);publish(()->result.accept(new Outcome(Status.FAILED,credit.record())));}else publish(()->result.accept(new Outcome(currentSession?Status.APPLIED:Status.STALE,credit.record())));});});
|
||||
publish(()->{boolean acknowledged=false;try{var current=context(player);acknowledged=current.isPresent()&¤t.orElseThrow().active().session().equals(captured.active().session())&&(credit.status()==CharacterStorage.MagicCoreStatus.DUPLICATE||sessions.acknowledgeProgress(captured.active(),captured.record().revision(),credit.record().orElseThrow()));}catch(RuntimeException uncertain){report(uncertain);}boolean currentSession=acknowledged;inventory.commitUse(operation).whenComplete((commitStatus,commitFailure)->{if(commitFailure!=null){report(commitFailure);publish(()->result.accept(new Outcome(Status.FAILED,credit.record())));}else {
|
||||
if(currentSession&&actor!=null&&credit.status()==CharacterStorage.MagicCoreStatus.APPLIED
|
||||
&&commitStatus==RareInventoryDirectory.UseStatus.COMMITTED)
|
||||
events.magicCoreUsed(operation,actor,quantity,captured.record(),credit.record().orElseThrow());
|
||||
publish(()->result.accept(new Outcome(currentSession?Status.APPLIED:Status.STALE,credit.record())));
|
||||
}});});
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.events.EventInput.Context safeActor(Player player,Context captured){
|
||||
try{return events.potionContext(player,captured.record().id(),captured.record().name(),captured.active().session());}
|
||||
catch(RuntimeException failure){report(failure);return null;}
|
||||
}
|
||||
private void refund(UUID operation,Runnable after){inventory.refundUse(operation).whenComplete((ignored,failure)->{if(failure!=null)report(failure);publish(after);});}
|
||||
private Optional<Context> context(Player player){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);return record==null?Optional.empty():Optional.of(new Context(active,record));}
|
||||
private void fail(Throwable failure,Consumer<Outcome> result){report(failure);publish(()->result.accept(new Outcome(Status.FAILED,Optional.empty())));}
|
||||
|
||||
@@ -14,17 +14,19 @@ public final class RareMarket implements AutoCloseable {
|
||||
public enum PurchaseStatus{APPLIED,SOLD_OUT,STALE,INSUFFICIENT,UNAVAILABLE,FAILED}
|
||||
public record PurchaseProof(UUID operation,RareProduct product,BigInteger price,int stockAfter){}
|
||||
public record PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog,PurchaseProof proof){public PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog){this(status,record,catalog,null);}}
|
||||
private final RareCatalogRepository catalogs;private final RarePurchaseOperationStore operations;private final CharacterStorage characters;private final RareInventoryDirectory inventory;private final Supplier<RareMarketRules> rules;private final Clock clock;private final RareCatalogGenerator.InclusiveRoll random;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-market"));private final CompletionStage<Void> readiness;private volatile RareCatalog snapshot;private volatile boolean initialized;private volatile Consumer<RareCatalog> listener=ignored->{};private boolean closed,operationsClosing;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
private final RareCatalogRepository catalogs;private final RarePurchaseOperationStore operations;private final CharacterStorage characters;private final RareInventoryDirectory inventory;private final Supplier<RareMarketRules> rules;private final Clock clock;private final RareCatalogGenerator.InclusiveRoll random;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-market"));private final CompletionStage<Void> readiness;private volatile RareCatalog snapshot;private Consumer<RareCatalog> listener=ignored->{};private final ArrayDeque<RareCatalog> pendingRefreshes=new ArrayDeque<>();private boolean listenerAttached,closed,operationsClosing;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public RareMarket(RareCatalogRepository catalogs,RarePurchaseOperationStore operations,CharacterStorage characters,RareInventoryDirectory inventory,Supplier<RareMarketRules> rules,Clock clock,RareCatalogGenerator.InclusiveRoll random){this.catalogs=Objects.requireNonNull(catalogs);this.operations=Objects.requireNonNull(operations);this.characters=Objects.requireNonNull(characters);this.inventory=Objects.requireNonNull(inventory);this.rules=Objects.requireNonNull(rules);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);readiness=submit(()->{initialize();return null;});}
|
||||
public void attachListener(Consumer<RareCatalog> listener){this.listener=Objects.requireNonNull(listener);}public Optional<RareCatalog> snapshot(){return Optional.ofNullable(snapshot);}public Instant now(){return clock.instant();}public CompletionStage<Void> ready(){return readiness;}public CompletionStage<RareCatalog> current(){return readiness.thenCompose(ignored->submit(()->ensureCurrent(RareCatalog.RefreshReason.SCHEDULED)));}
|
||||
public synchronized void attachListener(Consumer<RareCatalog> listener){this.listener=Objects.requireNonNull(listener);listenerAttached=true;while(!pendingRefreshes.isEmpty())deliver(pendingRefreshes.removeFirst());}public Optional<RareCatalog> snapshot(){return Optional.ofNullable(snapshot);}public Instant now(){return clock.instant();}public CompletionStage<Void> ready(){return readiness;}public CompletionStage<RareCatalog> current(){return readiness.thenCompose(ignored->submit(()->ensureCurrent(RareCatalog.RefreshReason.SCHEDULED)));}
|
||||
public CompletionStage<RareCatalog> restock(){return readiness.thenCompose(ignored->submit(()->{var current=ensureCurrent(RareCatalog.RefreshReason.SCHEDULED);if(!current.transactions().isEmpty())throw new IOException("Rare purchases are unresolved");var generated=RareCatalogGenerator.generate(LocalDate.now(clock.withZone(ZoneOffset.UTC)),clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),RareCatalog.RefreshReason.ADMINISTRATIVE,rules.get(),random);persist(generated,true);return generated;}));}
|
||||
public static Instant nextScheduledRestock(Instant now){return now.atZone(ZoneOffset.UTC).toLocalDate().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();}
|
||||
public CompletionStage<PurchaseOutcome> purchase(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product){return readiness.thenCompose(ignored->submit(()->purchaseBlocking(character,expectedCharacterRevision,expectedCatalogRevision,product)));}
|
||||
private void initialize()throws Exception{snapshot=catalogs.load().orElse(null);var plans=operations.load().toCompletableFuture().join();if(snapshot==null&&!plans.isEmpty())throw new IOException("Rare catalog missing for pending purchases");for(var plan:plans)execute(plan);if(snapshot!=null&&!snapshot.transactions().isEmpty()){var next=snapshot;for(var entry:snapshot.transactions().entrySet()){if(entry.getValue().state()==RareCatalog.TransactionState.RESERVED)throw new IOException("Orphan reserved rare purchase "+entry.getKey());next=next.prune(entry.getKey());}persist(next,false);}ensureCurrent(RareCatalog.RefreshReason.STARTUP);initialized=true;}
|
||||
private void initialize()throws Exception{snapshot=catalogs.load().orElse(null);var plans=operations.load().toCompletableFuture().join();if(snapshot==null&&!plans.isEmpty())throw new IOException("Rare catalog missing for pending purchases");for(var plan:plans)execute(plan);if(snapshot!=null&&!snapshot.transactions().isEmpty()){var next=snapshot;for(var entry:snapshot.transactions().entrySet()){if(entry.getValue().state()==RareCatalog.TransactionState.RESERVED)throw new IOException("Orphan reserved rare purchase "+entry.getKey());next=next.prune(entry.getKey());}persist(next,false);}ensureCurrent(RareCatalog.RefreshReason.STARTUP);}
|
||||
private PurchaseOutcome purchaseBlocking(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product)throws Exception{var catalog=ensureCurrent(RareCatalog.RefreshReason.SCHEDULED);if(!catalog.transactions().isEmpty())return new PurchaseOutcome(PurchaseStatus.FAILED,Optional.empty(),catalog);if(!catalog.revision().equals(expectedCatalogRevision))return new PurchaseOutcome(PurchaseStatus.STALE,Optional.empty(),catalog);if(!catalog.available(product))return new PurchaseOutcome(PurchaseStatus.UNAVAILABLE,Optional.empty(),catalog);if(catalog.offer(product).remaining()==0)return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);var plan=new RarePurchaseOperationStore.Plan(UUID.randomUUID(),character,expectedCharacterRevision,catalog.revision(),product,catalog.offer(product).price());operations.prepare(plan).toCompletableFuture().join();return execute(plan);}
|
||||
private PurchaseOutcome execute(RarePurchaseOperationStore.Plan plan)throws Exception{var catalog=Objects.requireNonNull(snapshot,"Rare catalog unavailable");if(!catalog.revision().equals(plan.catalogRevision()))throw new IOException("Pending rare purchase belongs to another catalog");var transaction=catalog.transactions().get(plan.id());if(transaction==null){try{catalog=catalog.reserve(plan.id(),plan.product());}catch(IllegalStateException soldOut){operations.complete(plan.id()).toCompletableFuture().join();return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);}persist(catalog,false);transaction=catalog.transactions().get(plan.id());}if(transaction.product()!=plan.product())throw new IOException("Rare reservation product mismatch");if(transaction.state()!=RareCatalog.TransactionState.RESERVED){if(transaction.state()==RareCatalog.TransactionState.CONSUMED)inventory.add(plan.character(),plan.product(),plan.id()).join();operations.complete(plan.id()).toCompletableFuture().join();var pruned=catalog.prune(plan.id());persist(pruned,false);return new PurchaseOutcome(transaction.state()==RareCatalog.TransactionState.CONSUMED?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,characters.find(plan.character()).join(),pruned,transaction.state()==RareCatalog.TransactionState.CONSUMED?new PurchaseProof(plan.id(),plan.product(),plan.price(),pruned.offer(plan.product()).remaining()):null);}var purchase=characters.purchaseRare(plan.character(),plan.expectedCharacterRevision(),plan.id(),plan.price()).join();boolean consumed=purchase.status()==CharacterStorage.RarePurchaseStatus.APPLIED||purchase.status()==CharacterStorage.RarePurchaseStatus.DUPLICATE;if(consumed)inventory.add(plan.character(),plan.product(),plan.id()).join();catalog=catalog.settle(plan.id(),consumed);persist(catalog,consumed);operations.complete(plan.id()).toCompletableFuture().join();catalog=catalog.prune(plan.id());persist(catalog,false);var status=switch(purchase.status()){case APPLIED,DUPLICATE->PurchaseStatus.APPLIED;case INSUFFICIENT->PurchaseStatus.INSUFFICIENT;case STALE->PurchaseStatus.STALE;case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;};return new PurchaseOutcome(status,purchase.record(),catalog,status==PurchaseStatus.APPLIED?new PurchaseProof(plan.id(),plan.product(),plan.price(),catalog.offer(plan.product()).remaining()):null);}
|
||||
private RareCatalog ensureCurrent(RareCatalog.RefreshReason reason)throws IOException{var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(snapshot!=null&&snapshot.day().equals(today))return snapshot;if(snapshot!=null&&!snapshot.transactions().isEmpty())throw new IOException("Rare purchases unresolved at rollover");var generated=RareCatalogGenerator.generate(today,clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),reason,rules.get(),random);persist(generated,true);return generated;}
|
||||
private void persist(RareCatalog catalog,boolean notify)throws IOException{catalogs.save(catalog);snapshot=catalog;if(notify&&initialized)try{listener.accept(catalog);}catch(RuntimeException ignored){}}
|
||||
private void persist(RareCatalog catalog,boolean notify)throws IOException{catalogs.save(catalog);snapshot=catalog;if(notify)notifyRefresh(catalog);}
|
||||
private synchronized void notifyRefresh(RareCatalog catalog){if(!listenerAttached)pendingRefreshes.addLast(catalog);else deliver(catalog);}
|
||||
private void deliver(RareCatalog catalog){try{listener.accept(catalog);}catch(RuntimeException ignored){}}
|
||||
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Rare market closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)closeOperations();}private void closeOperations(){if(operationsClosing)return;operationsClosing=true;operations.close();operations.drained().whenComplete((ignored,failure)->{if(failure==null)drained.complete(null);else drained.completeExceptionally(failure);});}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)closeOperations();}
|
||||
}
|
||||
@@ -20,13 +20,18 @@ public final class RarePotionService {
|
||||
public enum Status{APPLIED,FULL_HEALTH,NO_EFFECT,NOT_AVAILABLE,STALE,FAILED}
|
||||
public record Outcome(Status status,BigDecimal healed){}
|
||||
private record Context(EquipmentSessions.Active active,CharacterRecord record){}
|
||||
private final RareInventoryDirectory inventory;private final RareSettings settings;private final CharacterStorage characters;private final EquipmentSessions sessions;private final Consumer<EntityRegainHealthEvent> healingEvents;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;
|
||||
private final RareInventoryDirectory inventory;private final RareSettings settings;private final CharacterStorage characters;private final EquipmentSessions sessions;private final Consumer<EntityRegainHealthEvent> healingEvents;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private games.dmg.fantasystick.events.RareEventBridge events;
|
||||
public RarePotionService(RareInventoryDirectory inventory,RareSettings settings,CharacterStorage characters,EquipmentSessions sessions,Consumer<EntityRegainHealthEvent> healingEvents,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.inventory=Objects.requireNonNull(inventory);this.settings=Objects.requireNonNull(settings);this.characters=Objects.requireNonNull(characters);this.sessions=Objects.requireNonNull(sessions);this.healingEvents=Objects.requireNonNull(healingEvents);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);}
|
||||
public void attachEvents(games.dmg.fantasystick.events.RareEventBridge events){if(this.events!=null)throw new IllegalStateException("Rare potion events already attached");this.events=Objects.requireNonNull(events);}
|
||||
/** Ambiguous pre-restart native delivery is consumed but never replayed onto a replacement holder. */
|
||||
public void install(){for(var use:inventory.unresolvedUses())if(RareProduct.find(use.product()).map(RareProduct::heals).orElse(false))inventory.commitUse(use.operation()).exceptionally(failure->{report(failure);return RareInventoryDirectory.UseStatus.RESERVED;});}
|
||||
public BigInteger quantity(UUID character,RareProduct product){return inventory.quantity(character,product);}
|
||||
public void use(Player player,RareProduct product,Consumer<Outcome> result){if(!product.heals()){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var context=context(player);if(context.isEmpty()){result.accept(new Outcome(Status.STALE,BigDecimal.ZERO));return;}var captured=context.orElseThrow();var attribute=player.getAttribute(Attribute.MAX_HEALTH);double current=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue();if(!Double.isFinite(current)||!Double.isFinite(maximum)||maximum<=0||current>=maximum){result.accept(new Outcome(Status.FULL_HEALTH,BigDecimal.ZERO));return;}int percent=settings.rules().healingPercent().get(product);double proposed=Math.min(maximum-current,maximum*percent/100d);if(!Double.isFinite(proposed)||proposed<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var event=new EntityRegainHealthEvent(player,proposed,EntityRegainHealthEvent.RegainReason.CUSTOM);healingEvents.accept(event);double accepted=Math.min(proposed,Math.min(maximum-current,event.getAmount()));if(event.isCancelled()||!Double.isFinite(accepted)||accepted<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}UUID operation=UUID.randomUUID();BigInteger expectedQuantity=inventory.quantity(captured.record().id(),product);BigDecimal before=BigDecimal.valueOf(current),limit=BigDecimal.valueOf(maximum).min(NativeHealthLimit.read()),target=BigDecimal.valueOf(current+accepted).min(limit);inventory.reserveUse(captured.record().id(),product,operation,expectedQuantity,before,limit,percent).whenComplete((reservation,reserveFailure)->{if(reserveFailure!=null){report(reserveFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}if(reservation.status()!=RareInventoryDirectory.UseStatus.RESERVED){publish(()->result.accept(new Outcome(reservation.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE,BigDecimal.ZERO)));return;}characters.saveHealth(captured.record().id(),captured.record().revision(),target,limit).whenComplete((saved,saveFailure)->publish(()->complete(player,captured,operation,accepted,saved,saveFailure,result)));});}
|
||||
private void complete(Player player,Context captured,UUID operation,double accepted,Optional<CharacterRecord> saved,Throwable failure,Consumer<Outcome> result){if(failure!=null||saved==null||saved.isEmpty()){if(failure!=null)report(failure);inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));});return;}var persisted=saved.orElseThrow();var current=context(player);boolean acknowledged=false;try{acknowledged=current.isPresent()&¤t.orElseThrow().active().session().equals(captured.active().session())&&sessions.acknowledgeProgress(captured.active(),captured.record().revision(),persisted);}catch(RuntimeException uncertain){report(uncertain);}if(!acknowledged){inventory.commitUse(operation).whenComplete((ignored,commitFailure)->{if(commitFailure!=null)report(commitFailure);publish(()->result.accept(new Outcome(Status.STALE,BigDecimal.ZERO)));});return;}var attribute=player.getAttribute(Attribute.MAX_HEALTH);double now=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue(),desired=Math.min(maximum,persisted.health().doubleValue()),restored=Math.min(accepted,desired-now);if(!Double.isFinite(now)||!Double.isFinite(maximum)||!Double.isFinite(restored)||restored<=0){inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO)));});return;}try{player.setHealth(Math.min(maximum,now+restored));}catch(RuntimeException uncertain){report(uncertain);inventory.commitUse(operation);result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO));return;}inventory.commitUse(operation).whenComplete((status,commitFailure)->{if(commitFailure!=null){report(commitFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}publish(()->result.accept(new Outcome(Status.APPLIED,BigDecimal.valueOf(restored).stripTrailingZeros())));});}
|
||||
public void use(Player player,RareProduct product,Consumer<Outcome> result){if(!product.heals()){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var context=context(player);if(context.isEmpty()){result.accept(new Outcome(Status.STALE,BigDecimal.ZERO));return;}var captured=context.orElseThrow();var attribute=player.getAttribute(Attribute.MAX_HEALTH);double current=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue();if(!Double.isFinite(current)||!Double.isFinite(maximum)||maximum<=0||current>=maximum){result.accept(new Outcome(Status.FULL_HEALTH,BigDecimal.ZERO));return;}int percent=settings.rules().healingPercent().get(product);double proposed=Math.min(maximum-current,maximum*percent/100d);if(!Double.isFinite(proposed)||proposed<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var event=new EntityRegainHealthEvent(player,proposed,EntityRegainHealthEvent.RegainReason.CUSTOM);healingEvents.accept(event);double accepted=Math.min(proposed,Math.min(maximum-current,event.getAmount()));if(event.isCancelled()||!Double.isFinite(accepted)||accepted<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}UUID operation=UUID.randomUUID();BigInteger expectedQuantity=inventory.quantity(captured.record().id(),product);BigDecimal before=BigDecimal.valueOf(current),limit=BigDecimal.valueOf(maximum).min(NativeHealthLimit.read()),target=BigDecimal.valueOf(current+accepted).min(limit);inventory.reserveUse(captured.record().id(),product,operation,expectedQuantity,before,limit,percent).whenComplete((reservation,reserveFailure)->{if(reserveFailure!=null){report(reserveFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}if(reservation.status()!=RareInventoryDirectory.UseStatus.RESERVED){publish(()->result.accept(new Outcome(reservation.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE,BigDecimal.ZERO)));return;}characters.saveHealth(captured.record().id(),captured.record().revision(),target,limit).whenComplete((saved,saveFailure)->publish(()->complete(player,captured,operation,product,percent,expectedQuantity,accepted,saved,saveFailure,result)));});}
|
||||
private void complete(Player player,Context captured,UUID operation,RareProduct product,int percent,BigInteger quantityBefore,double accepted,Optional<CharacterRecord> saved,Throwable failure,Consumer<Outcome> result){if(failure!=null||saved==null||saved.isEmpty()){if(failure!=null)report(failure);inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));});return;}var persisted=saved.orElseThrow();var current=context(player);boolean acknowledged=false;try{acknowledged=current.isPresent()&¤t.orElseThrow().active().session().equals(captured.active().session())&&sessions.acknowledgeProgress(captured.active(),captured.record().revision(),persisted);}catch(RuntimeException uncertain){report(uncertain);}if(!acknowledged){inventory.commitUse(operation).whenComplete((ignored,commitFailure)->{if(commitFailure!=null)report(commitFailure);publish(()->result.accept(new Outcome(Status.STALE,BigDecimal.ZERO)));});return;}var attribute=player.getAttribute(Attribute.MAX_HEALTH);double now=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue(),desired=Math.min(maximum,persisted.health().doubleValue()),restored=Math.min(accepted,desired-now);if(!Double.isFinite(now)||!Double.isFinite(maximum)||!Double.isFinite(restored)||restored<=0){inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO)));});return;}try{player.setHealth(Math.min(maximum,now+restored));}catch(RuntimeException uncertain){report(uncertain);inventory.commitUse(operation);result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO));return;}var audit=events;var actor=audit==null?null:safePotionContext(audit,player,captured);var nativeBefore=BigDecimal.valueOf(now);var nativeAfter=BigDecimal.valueOf(now+restored);var nativeMaximum=BigDecimal.valueOf(maximum);inventory.commitUse(operation).whenComplete((status,commitFailure)->{if(commitFailure!=null){report(commitFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}if(status==RareInventoryDirectory.UseStatus.COMMITTED&&audit!=null&&actor!=null)audit.potionUsed(operation,product,actor,quantityBefore,percent,nativeBefore,nativeAfter,nativeMaximum,BigDecimal.valueOf(accepted),BigDecimal.valueOf(restored),captured.record().revision(),persisted.revision());publish(()->result.accept(new Outcome(Status.APPLIED,BigDecimal.valueOf(restored).stripTrailingZeros())));});}
|
||||
private games.dmg.fantasystick.events.EventInput.Context safePotionContext(games.dmg.fantasystick.events.RareEventBridge audit,Player player,Context captured){
|
||||
try{return audit.potionContext(player,captured.record().id(),captured.record().name(),captured.active().session());}
|
||||
catch(RuntimeException failure){report(failure);return null;}
|
||||
}
|
||||
private Optional<Context> context(Player player){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);return record==null?Optional.empty():Optional.of(new Context(active,record));}
|
||||
private void publish(Runnable action){try{nextTick.accept(action);}catch(RuntimeException failure){report(failure);}}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ public final class RareReskillService {
|
||||
BigInteger quantity, BigInteger refund) { }
|
||||
public record Outcome(Status status, Optional<CharacterRecord> record) { }
|
||||
private record Context(EquipmentSessions.Active active, CharacterRecord record) { }
|
||||
private record NativeAudit(games.dmg.fantasystick.events.EventInput.Context actor,
|
||||
java.math.BigDecimal health, java.math.BigDecimal maximum) { }
|
||||
|
||||
private final RareInventoryDirectory inventory;
|
||||
private final CharacterStorage characters;
|
||||
@@ -25,6 +27,12 @@ public final class RareReskillService {
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<CharacterRecord> cleanup;
|
||||
private final Consumer<Throwable> failures;
|
||||
private games.dmg.fantasystick.events.RareEventBridge events;
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.RareEventBridge events) {
|
||||
if (this.events != null) throw new IllegalStateException("Reskill events already attached");
|
||||
this.events = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
public RareReskillService(RareInventoryDirectory inventory, CharacterStorage characters,
|
||||
EquipmentSessions sessions, Consumer<Runnable> nextTick,
|
||||
@@ -42,35 +50,52 @@ public final class RareReskillService {
|
||||
if (context == null) return Optional.empty();
|
||||
var quantity = inventory.quantity(context.record().id(), RareProduct.RESKILL);
|
||||
if (quantity.signum() <= 0) return Optional.empty();
|
||||
return Optional.of(new Preview(UUID.randomUUID(), context.active(), context.record(), quantity,
|
||||
refund(context.record())));
|
||||
var preview = new Preview(UUID.randomUUID(), context.active(), context.record(), quantity,
|
||||
refund(context.record()));
|
||||
var captured = events == null ? null : nativeAudit(events, player, preview);
|
||||
if (captured != null) {
|
||||
events.reskillAction(preview.operation(), captured.actor(), "preview", "observed", "requested");
|
||||
events.reskillAction(preview.operation(), captured.actor(), "preview",
|
||||
preview.refund().signum() == 0 ? "refused" : "previewed",
|
||||
preview.refund().signum() == 0 ? "nothing_to_reset" : "confirmation_required");
|
||||
}
|
||||
return Optional.of(preview);
|
||||
}
|
||||
|
||||
public void confirm(Player player, Preview preview, Consumer<Outcome> result) {
|
||||
Objects.requireNonNull(player); Objects.requireNonNull(preview); Objects.requireNonNull(result);
|
||||
var captured = events == null ? null : nativeAudit(events, player, preview);
|
||||
if (captured != null) events.reskillAction(preview.operation(), captured.actor(), "confirmation", "observed", "requested");
|
||||
Consumer<Outcome> auditedResult = outcome -> {
|
||||
if (captured != null && outcome.status() != Status.APPLIED)
|
||||
events.reskillAction(preview.operation(), captured.actor(), "confirmation",
|
||||
outcome.status() == Status.FAILED ? "failed" : "refused",
|
||||
outcome.status().name().toLowerCase(java.util.Locale.ROOT));
|
||||
result.accept(outcome);
|
||||
};
|
||||
var current = context(player).orElse(null);
|
||||
if (current == null || !current.active().session().equals(preview.active().session())
|
||||
|| !current.record().revision().equals(preview.record().revision())
|
||||
|| !inventory.quantity(current.record().id(), RareProduct.RESKILL).equals(preview.quantity())) {
|
||||
result.accept(new Outcome(Status.STALE, Optional.ofNullable(current).map(Context::record)));
|
||||
auditedResult.accept(new Outcome(Status.STALE, Optional.ofNullable(current).map(Context::record)));
|
||||
return;
|
||||
}
|
||||
if (preview.refund().signum() == 0) {
|
||||
result.accept(new Outcome(Status.NOTHING_TO_RESET, Optional.of(current.record())));
|
||||
auditedResult.accept(new Outcome(Status.NOTHING_TO_RESET, Optional.of(current.record())));
|
||||
return;
|
||||
}
|
||||
var nativeLimit = NativeHealthLimit.read();
|
||||
inventory.reserveUse(current.record().id(), RareProduct.RESKILL, preview.operation(), preview.quantity())
|
||||
.whenComplete((reservation, reserveFailure) -> {
|
||||
if (reserveFailure != null) { fail(reserveFailure, result); return; }
|
||||
if (reserveFailure != null) { fail(reserveFailure, auditedResult); return; }
|
||||
if (reservation.status() != RareInventoryDirectory.UseStatus.RESERVED) {
|
||||
publish(() -> result.accept(new Outcome(
|
||||
publish(() -> auditedResult.accept(new Outcome(
|
||||
reservation.status() == RareInventoryDirectory.UseStatus.NOT_AVAILABLE
|
||||
? Status.NOT_AVAILABLE : Status.STALE, Optional.empty())));
|
||||
return;
|
||||
}
|
||||
characters.reskill(current.record().id(), current.record().revision(), preview.operation(), nativeLimit)
|
||||
.whenComplete((reset, resetFailure) -> complete(player, preview, reset, resetFailure, result));
|
||||
.whenComplete((reset, resetFailure) -> complete(player, preview, reset, resetFailure, auditedResult));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,6 +131,8 @@ public final class RareReskillService {
|
||||
return;
|
||||
}
|
||||
publish(() -> {
|
||||
var audit = events;
|
||||
var before = audit == null ? null : nativeAudit(audit, player, preview);
|
||||
boolean acknowledged = false;
|
||||
try {
|
||||
var current = context(player);
|
||||
@@ -116,13 +143,29 @@ public final class RareReskillService {
|
||||
} catch (RuntimeException uncertain) { report(uncertain); }
|
||||
if (acknowledged) try { cleanup.accept(preview.record()); } catch (RuntimeException uncertain) { report(uncertain); acknowledged=false; }
|
||||
boolean currentSession = acknowledged;
|
||||
inventory.commitUse(preview.operation()).whenComplete((ignored, commitFailure) -> {
|
||||
var after = acknowledged && before != null ? nativeAudit(audit, player, preview) : null;
|
||||
inventory.commitUse(preview.operation()).whenComplete((commitStatus, commitFailure) -> {
|
||||
if (commitFailure != null) { report(commitFailure); publish(() -> result.accept(new Outcome(Status.FAILED, reset.record()))); }
|
||||
else publish(() -> result.accept(new Outcome(currentSession ? Status.APPLIED : Status.STALE, reset.record())));
|
||||
else {
|
||||
if (currentSession && before != null && after != null
|
||||
&& reset.status() == CharacterStorage.ReskillStatus.APPLIED
|
||||
&& commitStatus == RareInventoryDirectory.UseStatus.COMMITTED)
|
||||
audit.reskillUsed(preview.operation(), before.actor(), preview.quantity(), preview.record(),
|
||||
reset.record().orElseThrow(), before.health(), after.health(), before.maximum(), after.maximum());
|
||||
publish(() -> result.accept(new Outcome(currentSession ? Status.APPLIED : Status.STALE, reset.record())));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private NativeAudit nativeAudit(games.dmg.fantasystick.events.RareEventBridge audit, Player player, Preview preview) {
|
||||
try {
|
||||
return new NativeAudit(audit.potionContext(player, preview.record().id(), preview.record().name(),
|
||||
preview.active().session()), java.math.BigDecimal.valueOf(player.getHealth()),
|
||||
java.math.BigDecimal.valueOf(player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH).getValue()));
|
||||
} catch (RuntimeException failure) { report(failure); return null; }
|
||||
}
|
||||
|
||||
private void refund(UUID operation, Runnable after) {
|
||||
inventory.refundUse(operation).whenComplete((ignored, failure) -> {
|
||||
if (failure != null) report(failure);
|
||||
|
||||
@@ -20,7 +20,12 @@ public final class RareShop {
|
||||
public void attachEvents(games.dmg.fantasystick.events.RareEventBridge events){if(this.events!=null)throw new IllegalStateException("Rare events already attached");this.events=Objects.requireNonNull(events);}
|
||||
public Optional<CharacterRecord> active(Player player,UUID shop){return context(player,shop).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID shop){return context(player,shop).map(value->value.active().session());}public Instant now(){return market.now();}
|
||||
public void view(Player player,UUID shopId,Consumer<Optional<View>> result){var captured=context(player,shopId);if(captured.isEmpty()){result.accept(Optional.empty());return;}market.current().whenComplete((catalog,failure)->{if(failure!=null){report(failure);publish(()->result.accept(Optional.empty()));return;}publish(()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.orElseThrow().active().session()))result.accept(Optional.empty());else result.accept(Optional.of(new View(current.orElseThrow().record(),current.orElseThrow().active().session(),catalog)));});});}
|
||||
public void buy(Player player,UUID shopId,UUID catalogRevision,RareProduct product,Consumer<Outcome> result){var lease=shops.acquire(shopId);if(lease.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var held=lease.orElseThrow();var context=context(player,shopId);if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();market.purchase(captured.record().id(),captured.record().revision(),catalogRevision,product).whenComplete((purchase,failure)->{if(failure!=null){report(failure);publishHeld(held,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}publishHeld(held,()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.active().session())){result.accept(new Outcome(Status.STALE,purchase.record()));return;}var status=switch(purchase.status()){case APPLIED->Status.APPLIED;case SOLD_OUT->Status.SOLD_OUT;case INSUFFICIENT->Status.INSUFFICIENT;case STALE->Status.STALE;case UNAVAILABLE,FAILED->Status.FAILED;};if(purchase.status()==RareMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent()){sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());if(product==RareProduct.PHOENIX_DOWN&&purchase.proof()!=null&&events!=null){var after=inventory.quantity(captured.record().id(),product);events.purchased(purchase.proof().operation(),purchase.catalog().revision(),player.getUniqueId(),player.getName(),captured.record().id(),captured.record().name(),captured.active().session(),purchase.proof().price(),purchase.proof().stockAfter()+1,purchase.proof().stockAfter(),after.subtract(java.math.BigInteger.ONE),after);}}result.accept(new Outcome(status,purchase.record()));});});}
|
||||
public void buy(Player player,UUID shopId,UUID catalogRevision,RareProduct product,Consumer<Outcome> result){var lease=shops.acquire(shopId);if(lease.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var held=lease.orElseThrow();var context=context(player,shopId);if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var auditLocation=(product==RareProduct.RESKILL||product==RareProduct.MAGIC_CORE)?purchaseLocation(player,captured):null;market.purchase(captured.record().id(),captured.record().revision(),catalogRevision,product).whenComplete((purchase,failure)->{if(failure!=null){report(failure);publishHeld(held,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}publishHeld(held,()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.active().session())){result.accept(new Outcome(Status.STALE,purchase.record()));return;}var status=switch(purchase.status()){case APPLIED->Status.APPLIED;case SOLD_OUT->Status.SOLD_OUT;case INSUFFICIENT->Status.INSUFFICIENT;case STALE->Status.STALE;case UNAVAILABLE,FAILED->Status.FAILED;};if(purchase.status()==RareMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent()){sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());if((product.heals()||product==RareProduct.PHOENIX_DOWN||product==RareProduct.RESKILL||product==RareProduct.MAGIC_CORE)&&purchase.proof()!=null&&events!=null){var after=inventory.quantity(captured.record().id(),product);var saved=purchase.record().orElseThrow();events.purchased(product,purchase.proof().operation(),purchase.catalog().revision(),shopId,player.getUniqueId(),player.getName(),captured.record().id(),captured.record().name(),captured.active().session(),purchase.proof().price(),purchase.proof().stockAfter()+1,purchase.proof().stockAfter(),after.subtract(java.math.BigInteger.ONE),after,captured.record().coins(),saved.coins(),captured.record().revision(),saved.revision(),auditLocation);}}result.accept(new Outcome(status,purchase.record()));});});}
|
||||
private games.dmg.fantasystick.events.EventInput.Location purchaseLocation(Player player,Context captured){
|
||||
if(events==null)return null;
|
||||
try{return events.potionContext(player,captured.record().id(),captured.record().name(),captured.active().session()).location();}
|
||||
catch(RuntimeException failure){report(failure);return null;}
|
||||
}
|
||||
private Optional<Context> context(Player player,UUID shopId){var shop=shops.find(shopId).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(shop==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(shop.world()))return Optional.empty();double x=location.getX()-shop.x(),y=location.getY()-shop.y(),z=location.getZ()-shop.z();return x*x+y*y+z*z<=36?Optional.of(new Context(active,record,shop)):Optional.empty();}
|
||||
private void publishHeld(RareMerchantDirectory.Lease lease,Runnable action){try{nextTick.accept(()->{try{action.run();}finally{lease.close();}});}catch(RuntimeException failure){lease.close();report(failure);}}private void publish(Runnable action){try{nextTick.accept(action);}catch(RuntimeException failure){report(failure);}}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
}
|
||||
@@ -10,6 +10,14 @@ import org.bukkit.entity.Player;
|
||||
public interface TrainingDamage {
|
||||
Optional<BigDecimal> apply(Player source, LivingEntity target, Runnable nativeDamage);
|
||||
|
||||
/** Caller-owned input identity; this metadata cannot authorize or replay an attack. */
|
||||
record Source(java.util.UUID actionId, java.util.UUID playerId, java.util.UUID characterId,
|
||||
String characterName, java.util.UUID sessionId, String attackName, String abilityId) {}
|
||||
|
||||
default Optional<BigDecimal> apply(Player source, LivingEntity target, Source metadata, Runnable nativeDamage) {
|
||||
return apply(source, target, nativeDamage);
|
||||
}
|
||||
|
||||
default boolean target(org.bukkit.entity.Entity entity) { return false; }
|
||||
|
||||
static TrainingDamage passthrough() {
|
||||
|
||||
@@ -4,7 +4,10 @@ import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Durable desired location for one administrator-managed training dummy. */
|
||||
public record TrainingDummy(UUID id, String name, UUID world, double x, double y, double z, float yaw, float pitch) {
|
||||
public record TrainingDummy(UUID id, String name, UUID world, double x, double y, double z, float yaw, float pitch, UUID revision) {
|
||||
public TrainingDummy(UUID id, String name, UUID world, double x, double y, double z, float yaw, float pitch) {
|
||||
this(id, name, world, x, y, z, yaw, pitch, null);
|
||||
}
|
||||
public TrainingDummy {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(world);
|
||||
name = name.strip();
|
||||
|
||||
@@ -31,9 +31,13 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
|
||||
private final java.util.Set<Zombie> loaded = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
|
||||
private Attempt pending;
|
||||
private boolean closed;
|
||||
private games.dmg.fantasystick.events.TrainingEventBridge events;
|
||||
|
||||
private static final class Attempt {
|
||||
final LivingEntity target; BigDecimal amount;
|
||||
final UUID causation = UUID.randomUUID();
|
||||
games.dmg.fantasystick.events.TrainingEventBridge.Capture capture;
|
||||
games.dmg.fantasystick.events.EventInput measurement;
|
||||
Attempt(LivingEntity target) { this.target = target; }
|
||||
}
|
||||
|
||||
@@ -49,6 +53,21 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
|
||||
}
|
||||
}
|
||||
|
||||
public record Observed(String chunkStatus, java.util.List<UUID> ids, String quality) {}
|
||||
public Observed observed(TrainingDummy value) {
|
||||
try {
|
||||
var world = plugin.getServer().getWorld(value.world());
|
||||
if (world == null) return new Observed("unavailable", java.util.List.of(), "unavailable");
|
||||
if (!world.isChunkLoaded(chunk(value.x()), chunk(value.z())))
|
||||
return new Observed("unloaded", java.util.List.of(), "native");
|
||||
var entities = world.getEntities();
|
||||
if (entities == null) return new Observed("loaded", java.util.List.of(), "unavailable");
|
||||
var ids = entities.stream().filter(this::tagged).filter(entity -> value.id().equals(id(entity)))
|
||||
.map(Entity::getUniqueId).sorted().toList();
|
||||
return new Observed("loaded", ids, "native");
|
||||
} catch (RuntimeException unavailable) { return new Observed("unavailable", java.util.List.of(), "unavailable"); }
|
||||
}
|
||||
|
||||
public boolean tagged(Entity entity) {
|
||||
var data = entity.getPersistentDataContainer();
|
||||
return data != null && TYPE.equals(data.get(typeKey, PersistentDataType.STRING));
|
||||
@@ -93,13 +112,25 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
|
||||
}
|
||||
}
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.TrainingEventBridge bridge) {
|
||||
if (closed || events != null) throw new IllegalStateException("Training events already attached or controller closed");
|
||||
events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
@Override public Optional<BigDecimal> apply(Player source, LivingEntity target, Runnable nativeDamage) {
|
||||
return apply(source, target, null, nativeDamage);
|
||||
}
|
||||
@Override public Optional<BigDecimal> apply(Player source, LivingEntity target, TrainingDamage.Source metadata, Runnable nativeDamage) {
|
||||
Objects.requireNonNull(source); Objects.requireNonNull(target); Objects.requireNonNull(nativeDamage);
|
||||
if (closed || !managed(target)) { nativeDamage.run(); return Optional.empty(); }
|
||||
if (pending != null) throw new IllegalStateException("Nested training damage is unavailable");
|
||||
var attempt = new Attempt(target); pending = attempt;
|
||||
var attempt = new Attempt(target);
|
||||
try { if (events != null && metadata != null) attempt.capture = events.capture(source, metadata); }
|
||||
catch (RuntimeException ignored) { /* Unavailable audit context cannot change interception. */ }
|
||||
pending = attempt;
|
||||
try { nativeDamage.run(); }
|
||||
finally { if (pending == attempt) pending = null; }
|
||||
if (events != null) events.measured(attempt.measurement);
|
||||
return Optional.ofNullable(attempt.amount);
|
||||
}
|
||||
|
||||
@@ -171,6 +202,12 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
|
||||
if (attempt != null && attempt.target == event.getEntity() && managed(event.getEntity())
|
||||
&& Double.isFinite(event.getFinalDamage()) && event.getFinalDamage() > 0) {
|
||||
attempt.amount = BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros();
|
||||
try {
|
||||
if (events != null && attempt.capture != null) {
|
||||
var dummy = directory.find(id(event.getEntity())).orElse(null);
|
||||
if (dummy != null) attempt.measurement = events.measurement(attempt.capture, attempt.causation, dummy, event, attempt.amount);
|
||||
}
|
||||
} catch (RuntimeException ignored) { /* Audit cannot interrupt native damage containment. */ }
|
||||
}
|
||||
event.setCancelled(true);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ public final class TrainingDummyDirectory implements AutoCloseable {
|
||||
}
|
||||
public CompletableFuture<TrainingDummy> place(String name, UUID world, double x, double y, double z,
|
||||
float yaw, float pitch) {
|
||||
var value = new TrainingDummy(UUID.randomUUID(), name, world, x, y, z, yaw, pitch);
|
||||
var value = new TrainingDummy(UUID.randomUUID(), name, world, x, y, z, yaw, pitch, UUID.randomUUID());
|
||||
return submit(() -> {
|
||||
var next = new ArrayList<>(snapshot); next.add(value); repository.save(next);
|
||||
snapshot = List.copyOf(next); return value;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class AdventureDayResetCountsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void actualAutomaticUtcRolloverPublishesOneSystemResetWithOriginalExpiredWorkAndDurableOfferData()throws Exception{
|
||||
var today=LocalDate.now(ZoneOffset.UTC);var day=today.minusDays(1);var started=day.atTime(12,0).toInstant(ZoneOffset.UTC);var site=new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.CAMP,"Unloaded saved camp",UUID.randomUUID(),12,70,-8);
|
||||
var before=new AdventureQuestState.Catalog(day,UUID.randomUUID(),started,List.of(site.id()));var character=UUID.randomUUID();
|
||||
var quest=new AdventureQuestState.Quest(UUID.randomUUID(),before.revision(),site.id(),AdventureQuestState.UnitType.SOLO,character,Set.of(character),before.expiresAt(),java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20));
|
||||
new YamlAdventureQuestRepository(data).save(new AdventureQuestState(List.of(site),List.of(),before,List.of(quest),Set.of(),Set.of(),Map.of(site.id(),List.of(org.bukkit.entity.EntityType.HUSK))));
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
fixture.pulse();ReskillEventsNativeTest.await(fixture.f,()->fixture.directory.snapshot().catalog().day().equals(today)&&fixture.directory.snapshot().reset()==null);
|
||||
awaitRows(fixture.f,1);var row=rows().getFirst();var values=row.getAsJsonObject("data");var fresh=fixture.directory.snapshot().catalog();
|
||||
assertEquals("system",values.get("actor_kind").getAsString());assertEquals("not_applicable",values.get("location_status").getAsString());assertFalse(row.has("playerid"));assertFalse(row.has("characterid"));assertEquals("utc_rollover",values.get("reason").getAsString());
|
||||
assertEquals(before.day().toString(),values.get("previous_day").getAsString());assertEquals(today.toString(),values.get("day").getAsString());assertEquals(before.revision().toString(),values.get("previous_catalog_id").getAsString());assertEquals(fresh.revision().toString(),values.get("generation_id").getAsString());assertNotEquals(before.revision(),fresh.revision());
|
||||
assertEquals(fresh.createdAt().toString(),values.get("created_at").getAsString());assertEquals(today.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC).toString(),values.get("expires_at").getAsString());assertEquals(1,values.get("expired_quest_count").getAsInt());assertEquals(0,values.get("cleaned_encounter_count").getAsInt());
|
||||
assertTrue(values.has("cleaned_run_count"),"Automatic reset must capture the actual registered dungeon participant's durable run cleanup count, including zero");assertEquals(0,values.get("cleaned_run_count").getAsInt());
|
||||
assertEquals(List.of(site),fixture.directory.snapshot().sites());assertTrue(fixture.directory.active().isEmpty());assertTrue(fixture.directory.snapshot().rosters().isEmpty());assertTrue(fixture.spawned.isEmpty(),"An unloaded definition is not permission to force-load or spawn");assertEquals(fresh,new YamlAdventureQuestRepository(data).load().catalog());
|
||||
fixture.pulse();fixture.pulse();fixture.f.plugin.setEnabled(false);fixture.f.awaitWorkers();WitchAuditTestSupport.drain(fixture.f,"adventureDayResetDrain");assertEquals(1,rows().size());assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(value->value.get("type").getAsString().endsWith("adventure.catalog_replaced")));
|
||||
}
|
||||
}
|
||||
@Test void confirmedRegisteredResetCapturesOneActualPersistedDungeonRunWithoutChangingItsDefinitionOrCharacter()throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
fixture.resetCatalog();awaitRows(fixture.f,1);var old=fixture.directory.snapshot().catalog();var dungeonDirectory=(games.dmg.fantasystick.dungeon.DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(fixture.f.plugin,"dungeons");
|
||||
var dungeon=dungeonDirectory.create("Saved progress",fixture.actor.world.getUID(),12,70,-8,0,0).get(5,TimeUnit.SECONDS);var run=dungeonDirectory.run(dungeon.id(),old.revision()).get(5,TimeUnit.SECONDS);assertEquals(List.of(run),dungeonDirectory.snapshot().runs());
|
||||
fixture.f.activate(fixture.actor);var before=CombatExperienceNativeTest.record(fixture.f,fixture.actor);fixture.resetCatalog();awaitRows(fixture.f,2);assertTrue(dungeonDirectory.snapshot().runs().isEmpty());assertEquals(List.of(dungeon),dungeonDirectory.snapshot().dungeons());assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));
|
||||
var row=rows().stream().filter(value->value.getAsJsonObject("data").has("previous_catalog_id")&&value.getAsJsonObject("data").get("previous_catalog_id").getAsString().equals(old.revision().toString())).findFirst().orElseThrow();var values=row.getAsJsonObject("data");
|
||||
assertTrue(values.has("cleaned_run_count"),"Actual dungeon progress cleared by the registered participant must be observed, not guessed from camp attempts");assertEquals(1,values.get("cleaned_run_count").getAsInt());assertEquals(0,values.get("cleaned_encounter_count").getAsInt());assertEquals(2,values.get("participant_count").getAsInt());
|
||||
}
|
||||
}
|
||||
private List<com.google.gson.JsonObject> rows()throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.day.reset")).toList();}
|
||||
private void awaitRows(FantasyStickLifecycleNativeTest.Fixture f,int count)throws Exception{ReskillEventsNativeTest.await(f,()->{try{return rows().size()==count;}catch(Exception failure){throw new AssertionError(failure);}});}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class AdventureDayResetEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void actualRegisteredConfirmedResetExportsOriginalAdministratorAndGenerationOnlyAfterActiveCleanupAndParticipantAcknowledgment()throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();ReskillEventsNativeTest.await(f,()->{try{return rows().size()==1;}catch(Exception failure){throw new AssertionError(failure);}});f.activate(actor);
|
||||
var before=CombatExperienceNativeTest.record(f,actor);var session=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).orElseThrow().session();
|
||||
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();fixture.pulse();
|
||||
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(entity->fixture.controller.encounterOrigin(entity).isPresent()));
|
||||
var old=fixture.directory.snapshot().catalog();assertTrue(fixture.controller.hasActiveEncounter());var originalSites=fixture.directory.snapshot().sites();var originalGivers=fixture.directory.snapshot().givers();int prior=rows().size();
|
||||
var observed=new AtomicReference<AdventureQuestState.Reset>();fixture.controller.registerResetParticipant(reset->{observed.set(reset);return java.util.concurrent.CompletableFuture.completedFuture(null);});
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure","reroll","confirm"});
|
||||
ReskillEventsNativeTest.await(f,()->!old.revision().equals(fixture.directory.snapshot().catalog().revision())&&fixture.directory.snapshot().reset()==null&&actor.messages.stream().filter(value->value.startsWith("Reset daily adventures:")).count()==2);
|
||||
var fresh=fixture.directory.snapshot().catalog();assertNotNull(observed.get());assertEquals(fresh.revision(),observed.get().generation());assertEquals(old.day(),fresh.day());assertEquals(old.expiresAt(),fresh.expiresAt());
|
||||
assertTrue(fixture.directory.active().isEmpty());assertFalse(fixture.controller.hasActiveEncounter());assertTrue(fixture.directory.snapshot().rosters().isEmpty());assertEquals(originalSites,fixture.directory.snapshot().sites());assertEquals(originalGivers,fixture.directory.snapshot().givers());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows().size()==prior+1;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
var exported=rows();assertEquals(prior+1,exported.size(),"A durably completed registered reset must export exactly one day-reset owner success after every participant acknowledges");
|
||||
var row=exported.stream().filter(value->value.getAsJsonObject("data").get("operation_id").getAsString().equals(observed.get().operation().toString())).findFirst().orElseThrow();var values=row.getAsJsonObject("data");
|
||||
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(before.id().toString(),row.get("characterid").getAsString());assertEquals(session.toString(),values.get("session_id").getAsString());assertEquals("manual",values.get("reason").getAsString());
|
||||
assertEquals(old.revision().toString(),values.get("previous_catalog_id").getAsString());assertEquals(fresh.revision().toString(),values.get("catalog_id").getAsString());assertEquals(old.day().toString(),values.get("previous_day").getAsString());assertEquals(fresh.day().toString(),values.get("day").getAsString());
|
||||
assertEquals(fresh.createdAt().toString(),values.get("created_at").getAsString());assertEquals(fresh.expiresAt().toString(),values.get("expires_at").getAsString());assertEquals(1,values.get("expired_quest_count").getAsInt());assertEquals(1,values.get("cleaned_encounter_count").getAsInt());
|
||||
assertEquals(1,values.get("selected_site_count").getAsInt());assertEquals(quest.site().toString(),values.getAsJsonArray("selected_site_ids").get(0).getAsString());assertEquals("acknowledged",values.get("outcome").getAsString());
|
||||
assertTrue(values.get("earned_rewards_preserved").getAsBoolean());assertTrue(values.get("inventory_preserved").getAsBoolean());assertTrue(values.get("current_generation_gates_reset").getAsBoolean());
|
||||
assertEquals(12,values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(70,values.getAsJsonObject("actor_location").get("y").getAsDouble());assertEquals(-8,values.getAsJsonObject("actor_location").get("z").getAsDouble());
|
||||
}
|
||||
}
|
||||
private java.util.List<com.google.gson.JsonObject> rows()throws Exception{
|
||||
return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.day.reset")).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
|
||||
import games.dmg.fantasystick.adventure.FishingDirectory;
|
||||
import games.dmg.fantasystick.events.AdventureDayResetEventBridge;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class AdventureDayResetRecoveryNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void failedParticipantRetainsOriginalOperationActorCountsAndEarnedSpoilsUntilSuccessfulRetry()throws Exception{
|
||||
try(var fixture=fixture()){
|
||||
prepare(fixture);earn(fixture);var trophies=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(fixture.f.plugin,"fishing");var earned=trophies.snapshot();assertEquals(1,earned.trophies().size());
|
||||
fixture.resetCatalog();awaitRows(fixture.f,2);accept(fixture);var before=CombatExperienceNativeTest.record(fixture.f,fixture.actor);var prior=fixture.directory.snapshot().catalog();var counter=new AtomicInteger();
|
||||
fixture.controller.registerResetParticipant(reset->counter.getAndIncrement()==0?CompletableFuture.failedFuture(new java.io.IOException("Injected participant failure")):CompletableFuture.completedFuture(null));
|
||||
command(fixture);ReskillEventsNativeTest.await(fixture.f,()->fixture.actor.messages.contains("Adventure reset is saved but pending recovery; the new generation is not yet available."));
|
||||
var pending=fixture.directory.snapshot().reset();assertNotNull(pending);assertEquals(2,rows().size());assertTrue(fixture.directory.active().isEmpty());assertFalse(fixture.controller.hasActiveEncounter());assertEquals(earned,trophies.snapshot());assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));
|
||||
fixture.actor.core.location.setX(77);assertTrue(fixture.controller.publishPendingReset().toCompletableFuture().get(5,TimeUnit.SECONDS));awaitRows(fixture.f,3);
|
||||
assertEquals(pending.generation(),fixture.directory.snapshot().catalog().revision());assertNull(fixture.directory.snapshot().reset());assertEquals(earned,trophies.snapshot());assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));
|
||||
var row=operation(pending.operation());var values=row.getAsJsonObject("data");assertEquals(prior.revision().toString(),values.get("previous_catalog_id").getAsString());assertEquals(12,values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(1,values.get("expired_quest_count").getAsInt());assertEquals(1,values.get("cleaned_encounter_count").getAsInt());assertEquals(3,values.get("participant_outcomes").getAsJsonArray().size());
|
||||
for(var outcome:values.getAsJsonArray("participant_outcomes"))assertEquals("acknowledged",outcome.getAsJsonObject().get("outcome").getAsString());assertEquals(2,counter.get());
|
||||
}
|
||||
}
|
||||
@Test void deniedMalformedStaleAndInitialFinalSaveFailuresCannotPublishResetSuccessAndRecoveryKeepsTheSavedGeneration()throws Exception{
|
||||
try(var fixture=fixture()){
|
||||
prepare(fixture);accept(fixture);var actor=fixture.actor;var f=fixture.f;var original=fixture.directory.snapshot();var character=CombatExperienceNativeTest.record(f,actor);var file=data.resolve("adventure-quests.yml");var bytes=Files.readAllBytes(file);
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure","reroll"});f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure","reroll","confirm","extra"});doReturn(false).when(actor.player).hasPermission("fantasystick.admin");command(fixture);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");assertEquals(original,fixture.directory.snapshot());assertArrayEquals(bytes,Files.readAllBytes(file));assertEquals(1,rows().size());
|
||||
block(file);command(fixture);ReskillEventsNativeTest.await(f,()->actor.messages.contains("Adventure reset failed; the current generation is unchanged."));assertEquals(original,fixture.directory.snapshot());assertEquals(character,CombatExperienceNativeTest.record(f,actor));assertEquals(1,rows().size());restore(file);
|
||||
var injected=new AtomicBoolean();fixture.controller.registerResetParticipant(reset->{if(!injected.compareAndSet(false,true))return CompletableFuture.completedFuture(null);return CompletableFuture.runAsync(()->{try{block(file);}catch(Exception failure){throw new java.util.concurrent.CompletionException(failure);}});});
|
||||
command(fixture);command(fixture);ReskillEventsNativeTest.await(f,()->actor.messages.contains("Adventure reset is saved but pending recovery; the new generation is not yet available."));ReskillEventsNativeTest.await(f,()->actor.messages.contains("Adventure reset refused: the generation changed or is still being published; inspect it and try again."));
|
||||
var pending=fixture.directory.snapshot().reset();assertNotNull(pending);assertEquals(1,rows().size());assertEquals(character,CombatExperienceNativeTest.record(f,actor));restore(file);assertTrue(fixture.controller.publishPendingReset().toCompletableFuture().get(5,TimeUnit.SECONDS));awaitRows(f,2);
|
||||
assertEquals(pending.generation(),fixture.directory.snapshot().catalog().revision());assertEquals(pending.operation().toString(),operation(pending.operation()).getAsJsonObject("data").get("operation_id").getAsString());assertEquals(character,CombatExperienceNativeTest.record(f,actor));
|
||||
assertEquals(AdventureQuestDirectory.RerollStatus.STALE,fixture.directory.rerollToday(original.catalog().revision()).get(5,TimeUnit.SECONDS).status());assertEquals(2,rows().size());
|
||||
}
|
||||
}
|
||||
@Test void shutdownBetweenParticipantAndFinalSourcePhasesRecoversOriginalActorAndCountsOfflineWithoutAnotherGenerationOrReward()throws Exception{
|
||||
try(var fixture=fixture()){
|
||||
prepare(fixture);earn(fixture);var oldTrophies=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(fixture.f.plugin,"fishing");var earned=oldTrophies.snapshot();fixture.resetCatalog();awaitRows(fixture.f,2);accept(fixture);
|
||||
var f=fixture.f;var actor=fixture.actor;var character=CombatExperienceNativeTest.record(f,actor);var session=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).orElseThrow().session();
|
||||
var held=new CompletableFuture<Void>();var reached=new AtomicBoolean();fixture.controller.registerResetParticipant(reset->{reached.set(true);return held;});command(fixture);ReskillEventsNativeTest.await(f,()->reached.get()&&fixture.directory.snapshot().reset()!=null);var pending=fixture.directory.snapshot().reset();assertEquals(2,rows().size());
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"adventureDayResetDrain");var inventory=actor.core.snapshot();var messages=List.copyOf(actor.messages);assertEquals(2,rows().size());assertNotNull(new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(data).load().reset());f.players.clear();clearInvocations(actor.player);f.plugin.setEnabled(true);
|
||||
var recovered=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");ReskillEventsNativeTest.await(f,()->recovered.snapshot().reset()==null);awaitRows(f,3);var values=operation(pending.operation()).getAsJsonObject("data");
|
||||
assertEquals(pending.generation(),recovered.snapshot().catalog().revision());assertEquals(session.toString(),values.get("session_id").getAsString());assertEquals(actor.core.id.toString(),operation(pending.operation()).get("playerid").getAsString());assertEquals(12,values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(1,values.get("cleaned_encounter_count").getAsInt());assertEquals(1,values.get("expired_quest_count").getAsInt());assertEquals(2,values.get("participant_count").getAsInt());assertEquals(earned,((FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing")).snapshot());
|
||||
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(character,storage.find(character.id()).get().orElseThrow());assertArrayEquals(inventory,actor.core.snapshot());assertEquals(messages,actor.messages);verifyNoInteractions(actor.player);
|
||||
assertTrue(held.complete(null));((AdventureDayResetEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureDayResetEvents")).replay();f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"adventureDayResetDrain");assertEquals(3,rows().size());assertEquals(pending.generation(),new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(data).load().catalog().revision());
|
||||
}
|
||||
}
|
||||
@Test void rejectedAcknowledgedResetReplaysEveryOriginalPropertyAndStableIdOfflineWithoutSourceOrInventoryChanges()throws Exception{
|
||||
try(var fixture=fixture()){
|
||||
prepare(fixture);accept(fixture);var f=fixture.f;var actor=fixture.actor;var character=CombatExperienceNativeTest.record(f,actor);((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();fixture.resetCatalog();var fresh=fixture.directory.snapshot().catalog();
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"adventureDayResetDrain");var original=intents(data.resolve("adventure-day-reset-event-intents"));assertEquals(1,original.size());assertEquals(1,rows().size());var bytes=Files.readAllBytes(data.resolve("adventure-quests.yml"));var inventory=actor.core.snapshot();var messages=List.copyOf(actor.messages);f.players.clear();clearInvocations(actor.player);f.plugin.setEnabled(true);awaitRows(f,2);
|
||||
var row=rows().stream().filter(value->value.get("id").getAsString().equals(original.getFirst().getProperty("id"))).findFirst().orElseThrow();assertOriginal(original.getFirst(),row);assertArrayEquals(bytes,Files.readAllBytes(data.resolve("adventure-quests.yml")));assertArrayEquals(inventory,actor.core.snapshot());assertEquals(messages,actor.messages);verifyNoInteractions(actor.player);assertEquals(fresh,new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(data).load().catalog());
|
||||
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(character,storage.find(character.id()).get().orElseThrow());var bridge=(AdventureDayResetEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureDayResetEvents");bridge.replay();bridge.replay();f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"adventureDayResetDrain");assertEquals(2,rows().size());
|
||||
}
|
||||
}
|
||||
@Test void unavailableObservationFactsJournalCannotAuthorizeRefuseOrUndoActualRegisteredGameplayReset()throws Exception{
|
||||
try(var fixture=fixture()){
|
||||
prepare(fixture);accept(fixture);var before=CombatExperienceNativeTest.record(fixture.f,fixture.actor);var old=fixture.directory.snapshot().catalog();
|
||||
var folder=data.resolve("adventure-day-reset-facts");
|
||||
if(Files.exists(folder)){try(var files=Files.list(folder)){for(var file:files.toList())Files.delete(file);}Files.delete(folder);}Files.writeString(folder,"unavailable observation storage");
|
||||
fixture.resetCatalog();awaitRows(fixture.f,2);assertNotEquals(old.revision(),fixture.directory.snapshot().catalog().revision());assertNull(fixture.directory.snapshot().reset());assertTrue(fixture.directory.active().isEmpty());assertFalse(fixture.controller.hasActiveEncounter());assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));
|
||||
var row=rows().stream().filter(value->value.getAsJsonObject("data").has("previous_catalog_id")&&value.getAsJsonObject("data").get("previous_catalog_id").getAsString().equals(old.revision().toString())).findFirst().orElseThrow();assertEquals(1,row.getAsJsonObject("data").get("cleaned_encounter_count").getAsInt());assertEquals("acknowledged",row.getAsJsonObject("data").get("outcome").getAsString());
|
||||
}
|
||||
}
|
||||
private CampAuditNativeFixture fixture()throws Exception{var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);config.save(data.resolve("config.yml").toFile());return new CampAuditNativeFixture(data);}
|
||||
private void prepare(CampAuditNativeFixture fixture)throws Exception{fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();awaitRows(fixture.f,1);fixture.f.activate(fixture.actor);}
|
||||
private void accept(CampAuditNativeFixture fixture)throws Exception{fixture.click(fixture.actor,fixture.open(fixture.actor),false);ReskillEventsNativeTest.await(fixture.f,()->fixture.directory.active().size()==1);fixture.pulse();ReskillEventsNativeTest.await(fixture.f,()->fixture.spawned.stream().filter(Entity::isValid).filter(entity->fixture.controller.encounterOrigin(entity).isPresent()).count()==4);}
|
||||
private void earn(CampAuditNativeFixture fixture)throws Exception{
|
||||
accept(fixture);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(fixture.actor.player);when(source.getDirectEntity()).thenReturn(fixture.actor.player);
|
||||
for(var entity:fixture.spawned.stream().filter(Entity::isValid).filter(value->fixture.controller.encounterOrigin(value).isPresent()).toList()){
|
||||
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(fixture.actor.player,entity,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(entity.getLastDamageCause()).thenReturn(hit);fixture.f.dispatcher.accept(hit);assertFalse(hit.isCancelled());fixture.f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(entity,source,new ArrayList<>(),0));
|
||||
}
|
||||
var trophies=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(fixture.f.plugin,"fishing");ReskillEventsNativeTest.await(fixture.f,()->fixture.directory.active().isEmpty()&&trophies.trophies(fixture.actor.core.record.id()).size()==1);
|
||||
}
|
||||
private static void command(CampAuditNativeFixture fixture){fixture.f.adminCommand.execute(fixture.actor.player,"fstickadmin",new String[]{"adventure","reroll","confirm"});}
|
||||
private void block(Path file)throws Exception{Files.move(file,file.resolveSibling(file.getFileName()+".test-backup"));Files.createDirectory(file);Files.writeString(file.resolve("block"),"retain");}
|
||||
private void restore(Path file)throws Exception{Files.delete(file.resolve("block"));Files.delete(file);Files.move(file.resolveSibling(file.getFileName()+".test-backup"),file);}
|
||||
private List<com.google.gson.JsonObject> rows()throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.day.reset")).toList();}
|
||||
private com.google.gson.JsonObject operation(java.util.UUID operation)throws Exception{return rows().stream().filter(row->row.getAsJsonObject("data").get("operation_id").getAsString().equals(operation.toString())).findFirst().orElseThrow();}
|
||||
private void awaitRows(FantasyStickLifecycleNativeTest.Fixture f,int count)throws Exception{ReskillEventsNativeTest.await(f,()->{try{return rows().size()==count;}catch(Exception failure){throw new AssertionError(failure);}});}
|
||||
}
|
||||
@@ -393,11 +393,14 @@ class AdventureQuestNativeTest {
|
||||
await(restarted,()->!fishing.trophies(carrier).isEmpty());
|
||||
assertEquals(finalEnemy.toString(),fishing.trophies(carrier).getFirst().audit()
|
||||
.get("final_enemy_id"));
|
||||
awaitCampExport(restarted,data,receiptId);
|
||||
restarted.plugin.setEnabled(false);
|
||||
}
|
||||
var earned=exportedCampEvents(data).stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("adventure.trophy_earned")).toList();
|
||||
assertEquals(1,earned.size());
|
||||
assertEquals(1,earned.size(),()->"Exported camp earned count="+earned.size()+" records="
|
||||
+earned.stream().map(event->"id="+event.get("id").getAsString()+",receipt="
|
||||
+event.getAsJsonObject("data").get("receipt_id").getAsString()).toList());
|
||||
var details=earned.getFirst().getAsJsonObject("data");
|
||||
assertEquals(receiptId.toString(),details.get("receipt_id").getAsString());
|
||||
assertEquals(finalEnemy.toString(),details.get("final_enemy_id").getAsString());
|
||||
@@ -779,6 +782,30 @@ class AdventureQuestNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitCampExport(FantasyStickLifecycleNativeTest.Fixture fixture,Path root,
|
||||
UUID receipt)throws Exception{
|
||||
var eventId=UUID.nameUUIDFromBytes(("fantasystick:fishing:"+receipt
|
||||
+":adventure.trophy_earned").getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
var ownerIntent=root.resolve("fishing-event-intents").resolve(eventId+".pending");
|
||||
var logIntent=root.resolve("events/pending").resolve(eventId+".pending");
|
||||
for(int attempt=0;attempt<200;attempt++){
|
||||
if(hasExportedCampReceipt(root,receipt)&&java.nio.file.Files.notExists(ownerIntent)
|
||||
&&java.nio.file.Files.notExists(logIntent))return;
|
||||
var task=fixture.tasks.poll(25,java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
if(task!=null)task.run();
|
||||
}
|
||||
assertTrue(hasExportedCampReceipt(root,receipt)
|
||||
&&java.nio.file.Files.notExists(ownerIntent)&&java.nio.file.Files.notExists(logIntent),
|
||||
"Committed camp receipt "+receipt+" did not complete exported JSONL/intent handoff before shutdown"
|
||||
+"; owner_pending="+java.nio.file.Files.exists(ownerIntent)
|
||||
+"; log_pending="+java.nio.file.Files.exists(logIntent));
|
||||
}
|
||||
private static boolean hasExportedCampReceipt(Path root,UUID receipt){
|
||||
try{return exportedCampEvents(root).stream().anyMatch(event->event.get("type").getAsString()
|
||||
.endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data")
|
||||
.get("receipt_id").getAsString().equals(receipt.toString()));}
|
||||
catch(Exception transientExport){return false;}
|
||||
}
|
||||
private static List<com.google.gson.JsonObject> exportedCampEvents(Path root)throws Exception{
|
||||
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
|
||||
return files.filter(path->path.toString().endsWith(".jsonl"))
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ArenaAttemptEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
|
||||
@Test void actualAcceptedRectangleOutsideThenExactCornerActivationFreezesOriginalActorGeometryThroughLaterDefinitionAndNativeDefeat()throws Exception{
|
||||
try(var championFixture=new ChampionAuditNativeFixture(data)){
|
||||
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","champion","--mode","individual-only","8","16","Edge Champion"});
|
||||
ReskillEventsNativeTest.await(f,()->fixture.directory.snapshot().sites().size()==1);var site=fixture.directory.snapshot().sites().getFirst();
|
||||
fixture.giver();fixture.resetCatalog();f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
|
||||
var session=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).orElseThrow().session();
|
||||
var menu=fixture.open(actor);actor.core.location.setX(site.x()+4.01);actor.core.location.setZ(site.z()+8);
|
||||
fixture.click(actor,menu,false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();
|
||||
fixture.pulse();f.tick();
|
||||
assertTrue(fixture.spawned.stream().noneMatch(org.bukkit.entity.Vindicator.class::isInstance),"An accepted but horizontally outside holder cannot activate the rectangle");
|
||||
actor.core.location.setX(site.x()+4);actor.core.location.setY(site.y()+40);fixture.pulse();
|
||||
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).count()==1);var champion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
|
||||
String attempt=champion.getPersistentDataContainer().get(new org.bukkit.NamespacedKey(f.plugin,"adventure_attempt_id"),org.bukkit.persistence.PersistentDataType.STRING);
|
||||
assertTrue(fixture.controller.hasActiveEncounter());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
var source=mock(DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
|
||||
var cancelled=new EntityDamageByEntityEvent(actor.player,champion,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",site.id().toString(),"party-required"});
|
||||
ReskillEventsNativeTest.await(f,()->fixture.directory.findSite(site.id()).orElseThrow().participationMode()==games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_REQUIRED);
|
||||
assertNotEquals(site.registrationRevision(),fixture.directory.findSite(site.id()).orElseThrow().registrationRevision());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());
|
||||
var hit=new EntityDamageByEntityEvent(actor.player,champion,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(champion.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
|
||||
var death=new EntityDeathEvent(champion,source,new ArrayList<>(),7);f.dispatcher.accept(death);f.dispatcher.accept(death);
|
||||
ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty());assertTrue(death.getDrops().isEmpty());assertEquals(0,death.getDroppedExp());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.spawned").size()==1&&rows("adventure.champion.defeated").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var start=rows("adventure.champion.spawned").getFirst().getAsJsonObject("data");var defeat=rows("adventure.champion.defeated").getFirst().getAsJsonObject("data");
|
||||
var arena=start.getAsJsonObject("arena");assertNotNull(arena,"Native activation must export the exact captured arena, not only generic attempt identity");
|
||||
assertEquals("rectangle",arena.get("shape").getAsString());assertEquals("explicit",arena.get("source").getAsString());assertEquals(8,arena.get("x_blocks").getAsInt());assertEquals(16,arena.get("z_blocks").getAsInt());
|
||||
assertEquals(site.registrationRevision().toString(),start.get("arena_revision").getAsString());assertEquals(arena,defeat.getAsJsonObject("arena"));assertEquals(start.get("arena_revision"),defeat.get("arena_revision"));
|
||||
var activation=start.getAsJsonObject("activation");assertNotNull(activation);assertEquals("edge",activation.get("boundary_result").getAsString());
|
||||
assertEquals(actor.core.id.toString(),activation.get("player_id").getAsString());assertEquals(before.id().toString(),activation.get("character_id").getAsString());assertEquals(session.toString(),activation.get("session_id").getAsString());
|
||||
assertEquals(quest.id().toString(),activation.get("quest_id").getAsString());assertEquals(quest.unit().toString(),activation.get("unit_id").getAsString());
|
||||
assertEquals(site.world().toString(),activation.get("location_world_id").getAsString());assertEquals(site.x()+4,activation.get("location_x").getAsDouble());assertEquals(site.y()+40,activation.get("location_y").getAsDouble());assertEquals(site.z()+8,activation.get("location_z").getAsDouble());
|
||||
assertEquals("rectangle",activation.get("arena_shape").getAsString());assertEquals(8,activation.get("arena_x_blocks").getAsInt());assertEquals(16,activation.get("arena_z_blocks").getAsInt());
|
||||
assertEquals(activation,defeat.getAsJsonObject("activation"));assertEquals(attempt,start.get("attempt_id").getAsString());assertEquals(attempt,defeat.get("attempt_id").getAsString());
|
||||
assertEquals(1,rows("adventure.champion.spawned").size());assertEquals(1,rows("adventure.champion.defeated").size());assertTrue(rows("adventure.champion.reset").isEmpty());
|
||||
assertEquals(champion.getLocation().getX(),defeat.getAsJsonObject("final_enemy_location").get("x").getAsDouble());
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource({"camp,circle,edge","camp,rectangle,inside","camp,default,inside","champion,circle,inside","champion,rectangle,edge","champion,default,edge"})
|
||||
void actualAcceptedArenaVariantsExcludeOutsideAndUnacceptedPlayersThenPreserveGeometryActivationAndQuietPulsesThroughReset(String kind,String form,String result)throws Exception{
|
||||
try(var championFixture=new ChampionAuditNativeFixture(data)){
|
||||
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;
|
||||
var args=new java.util.ArrayList<>(java.util.List.of("adventure-site","place",kind,"--mode","individual-only"));
|
||||
if(form.equals("circle"))args.add("10");else if(form.equals("rectangle")){args.add("20");args.add("30");}args.add("Arena Variant");
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",args.toArray(String[]::new));ReskillEventsNativeTest.await(f,()->fixture.directory.snapshot().sites().size()==1);var site=fixture.directory.snapshot().sites().getFirst();
|
||||
fixture.giver();fixture.resetCatalog();f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);var arena=fixture.controller.arena(site);double halfX=form.equals("rectangle")?10:arena.xBlocks();
|
||||
var menu=fixture.open(actor);actor.core.location.setX(site.x()+halfX+0.01);fixture.click(actor,menu,false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();
|
||||
var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);outsider.core.location.setX(site.x());outsider.core.location.setY(site.y());outsider.core.location.setZ(site.z());f.players.put(outsider.core.id,outsider.player);f.activate(outsider);
|
||||
fixture.pulse();f.tick();assertFalse(fixture.controller.hasActiveEncounter(),"Neither an outside accepted holder nor an inside unaccepted living character can activate");
|
||||
assertTrue(fixture.spawned.stream().noneMatch(entity->fixture.controller.encounterOrigin(entity).isPresent()));
|
||||
double offset=result.equals("edge")?halfX:halfX/2;actor.core.location.setX(site.x()+offset);actor.core.location.setY(site.y()+40);fixture.pulse();
|
||||
ReskillEventsNativeTest.await(f,fixture.controller::hasActiveEncounter);ReskillEventsNativeTest.await(f,()->{
|
||||
var roster=fixture.directory.snapshot().rosters().get(site.id());
|
||||
return (kind.equals("champion")||roster!=null)&&fixture.spawned.stream().filter(entity->fixture.controller.encounterOrigin(entity).isPresent()).count()==(kind.equals("champion")?1:roster.size());
|
||||
});
|
||||
String startType=kind.equals("champion")?"adventure.champion.spawned":"adventure.camp.started",resetType="adventure."+kind+".reset";
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows(startType).size()==1;}catch(Exception failure){throw new AssertionError(failure);}});var start=rows(startType).getFirst();var values=start.getAsJsonObject("data");var originalGeometry=values.getAsJsonObject("arena").deepCopy();var activation=values.getAsJsonObject("activation").deepCopy();
|
||||
assertEquals(result,activation.get("boundary_result").getAsString());assertEquals(actor.core.id.toString(),activation.get("player_id").getAsString());assertEquals(before.id().toString(),activation.get("character_id").getAsString());assertEquals(quest.id().toString(),activation.get("quest_id").getAsString());
|
||||
assertEquals(site.x()+offset,activation.get("location_x").getAsDouble());assertEquals(site.y()+40,activation.get("location_y").getAsDouble());assertEquals(site.z(),activation.get("location_z").getAsDouble());
|
||||
assertEquals(form.equals("rectangle")?"rectangle":"circle",originalGeometry.get("shape").getAsString());assertEquals(form.equals("default")?"configured_default":"explicit",originalGeometry.get("source").getAsString());assertEquals(arena.xBlocks(),originalGeometry.get("x_blocks").getAsInt());assertEquals(arena.zBlocks(),originalGeometry.get("z_blocks").getAsInt());
|
||||
actor.particleCalls.clear();for(int index=0;index<3;index++)fixture.pulse();assertFalse(actor.particleCalls.isEmpty(),"Actual accepted perimeter presentation remains wired");assertEquals(java.util.List.of(start),rows(startType));
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config","adventure-site-radius","32"});ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved adventure-site-radius=32")));
|
||||
actor.core.location.setX(site.x()+halfX+1);f.dispatcher.accept(new org.bukkit.event.world.ChunkUnloadEvent(actor.world.getChunkAt(0,-1),false));assertFalse(fixture.controller.hasActiveEncounter());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
assertTrue(((games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing")).trophies(quest.unit()).isEmpty());
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows(resetType).size()==1;}catch(Exception failure){throw new AssertionError(failure);}});f.plugin.setEnabled(false);f.awaitWorkers();drain(f,kind.equals("camp")?"campAuditDrain":"championAuditDrain");
|
||||
var reset=rows(resetType).getFirst().getAsJsonObject("data");assertEquals(originalGeometry,reset.getAsJsonObject("arena"));assertEquals(activation,reset.getAsJsonObject("activation"));assertEquals(values.get("attempt_id"),reset.get("attempt_id"));assertEquals(values.get("arena_revision"),reset.get("arena_revision"));assertEquals("chunk_unload",reset.get("reason").getAsString());
|
||||
assertEquals(1,rows(startType).size());assertEquals(1,rows(resetType).size());assertTrue(rows(kind.equals("champion")?"adventure.champion.defeated":"adventure.camp.cleared").isEmpty());
|
||||
}
|
||||
}
|
||||
private java.util.List<com.google.gson.JsonObject> rows(String type)throws Exception{
|
||||
return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ArenaRegistrationEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
|
||||
@Test void actualRegisteredExplicitCirclePreservesSavedGeometryAndOriginalAdministratorArenaContextInJsonl()throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
|
||||
var session=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions"))
|
||||
.active(actor.core.id).orElseThrow().session();
|
||||
assertTrue(f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","champion","--mode","individual-only","8","Round Arena"}));
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Placed champion")));
|
||||
var saved=fixture.directory.snapshot().sites().getFirst();assertEquals(AdventureQuestState.Arena.circle(8),saved.arena());
|
||||
assertEquals(saved,new YamlAdventureQuestRepository(data).load().sites().getFirst());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows().size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var rows=rows();assertEquals(1,rows.size());
|
||||
var row=rows.getFirst();var values=row.getAsJsonObject("data");var arena=values.getAsJsonObject("arena");
|
||||
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(before.id().toString(),row.get("characterid").getAsString());
|
||||
assertEquals(session.toString(),values.get("session_id").getAsString());assertEquals(saved.id().toString(),values.get("site_id").getAsString());
|
||||
assertEquals(saved.world().toString(),values.get("world_id").getAsString());assertEquals(12,values.get("x").getAsDouble());
|
||||
assertEquals(70,values.get("y").getAsDouble());assertEquals(-8,values.get("z").getAsDouble());assertEquals("circle",arena.get("shape").getAsString());
|
||||
assertEquals("explicit",arena.has("source")?arena.get("source").getAsString():null,
|
||||
"Acknowledged registration must distinguish persisted explicit geometry from configured-default context in its actual JSONL record");
|
||||
assertEquals(8,arena.get("radius").getAsInt());assertEquals(saved.registrationRevision().toString(),values.get("arena_revision").getAsString());
|
||||
assertEquals("persisted_site_definition",values.get("arena_revision_capture_quality").getAsString());
|
||||
assertEquals("individual-only",values.get("mode_after").getAsString());assertEquals("acknowledged",values.get("outcome").getAsString());
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource({"camp,circle","camp,rectangle","camp,default","champion,circle","champion,rectangle","champion,default"})
|
||||
void allRegisteredArenaFormsRetainOriginalEffectiveGeometryAcrossLaterConfigurationAndReload(String kind,String form)throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config","adventure-site-radius","24"});
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved adventure-site-radius=24")));
|
||||
var args=new java.util.ArrayList<>(java.util.List.of("adventure-site","place",kind,"--mode","individual-only"));
|
||||
if(form.equals("circle"))args.add("10");else if(form.equals("rectangle")){args.add("20");args.add("30");}args.add("Geometry Original");
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",args.toArray(String[]::new));
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Placed "+kind)));
|
||||
var site=fixture.directory.snapshot().sites().getFirst();assertEquals(site,new YamlAdventureQuestRepository(data).load().sites().getFirst());
|
||||
if(form.equals("default"))assertNull(site.arena());else assertEquals(form.equals("circle")?AdventureQuestState.Arena.circle(10):AdventureQuestState.Arena.rectangle(20,30),site.arena());
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config","adventure-site-radius","32"});
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved adventure-site-radius=32")));
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","list"});fixture.pulse();fixture.pulse();
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows(kind).size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,kind.equals("camp")?"campAuditDrain":"championAuditDrain");
|
||||
var row=rows(kind).getFirst();var values=row.getAsJsonObject("data");var geometry=values.getAsJsonObject("arena");
|
||||
assertEquals(form.equals("rectangle")?"rectangle":"circle",geometry.get("shape").getAsString());assertEquals(form.equals("default")?"configured_default":"explicit",geometry.get("source").getAsString());
|
||||
assertEquals(form.equals("default")?24:form.equals("rectangle")?20:10,geometry.get("x_blocks").getAsInt());assertEquals(form.equals("default")?24:form.equals("rectangle")?30:10,geometry.get("z_blocks").getAsInt());
|
||||
if(form.equals("rectangle"))assertFalse(geometry.has("radius"));else assertEquals(form.equals("default")?24:10,geometry.get("radius").getAsInt());
|
||||
assertEquals(site.registrationRevision().toString(),values.get("arena_revision").getAsString());assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(before.id().toString(),row.get("characterid").getAsString());
|
||||
assertEquals(12,values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(70,values.getAsJsonObject("actor_location").get("y").getAsDouble());assertEquals(-8,values.getAsJsonObject("actor_location").get("z").getAsDouble());
|
||||
assertEquals(site.world().toString(),values.get("world_id").getAsString());assertEquals(1,rows(kind).size());assertTrue(fixture.spawned.isEmpty());
|
||||
var bytes=java.nio.file.Files.readAllBytes(data.resolve("adventure-quests.yml"));f.players.clear();f.plugin.setEnabled(true);
|
||||
ReskillEventsNativeTest.await(f,()->((games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests")).findSite(site.id()).isPresent());
|
||||
assertArrayEquals(bytes,java.nio.file.Files.readAllBytes(data.resolve("adventure-quests.yml")));assertEquals(site,((games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests")).findSite(site.id()).orElseThrow());
|
||||
assertEquals(before,((games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters")).find(before.id()).get().orElseThrow());
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,kind.equals("camp")?"campAuditDrain":"championAuditDrain");assertEquals(java.util.List.of(row),rows(kind));
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings={"camp","champion"})
|
||||
void actualMalformedDeniedAndFailedRegistrationProduceNoArenaSuccessWhileRetryAndListRemainExact(String kind)throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;f.activate(actor);
|
||||
actor.allowed=false;f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place",kind,"10","Denied"});actor.allowed=true;
|
||||
for(var dimensions:java.util.List.of(new String[]{"3"},new String[]{"65"},new String[]{"20","7"},new String[]{"20","129"},new String[]{"1.5"},new String[]{"--mode","invalid","10"})){
|
||||
var args=new java.util.ArrayList<>(java.util.List.of("adventure-site","place",kind));args.addAll(java.util.List.of(dimensions));f.adminCommand.execute(actor.player,"fstickadmin",args.toArray(String[]::new));
|
||||
}
|
||||
assertTrue(fixture.directory.snapshot().sites().isEmpty());
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place",kind,"10","Original"});ReskillEventsNativeTest.await(f,()->fixture.directory.snapshot().sites().size()==1);
|
||||
var original=fixture.directory.snapshot().sites().getFirst();var file=data.resolve("adventure-quests.yml");var bytes=java.nio.file.Files.readAllBytes(file);java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place",kind,"20","30","Retry"});ReskillEventsNativeTest.await(f,()->actor.messages.contains("Adventure site save failed; desired state is unchanged."));
|
||||
assertEquals(java.util.List.of(original),fixture.directory.snapshot().sites());java.nio.file.Files.delete(file);java.nio.file.Files.write(file,bytes);
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place",kind,"20","30","Retry"});ReskillEventsNativeTest.await(f,()->fixture.directory.snapshot().sites().size()==2);
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","list"});fixture.pulse();fixture.pulse();
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows(kind).size()==2;}catch(Exception failure){throw new AssertionError(failure);}});f.plugin.setEnabled(false);f.awaitWorkers();drain(f,kind.equals("camp")?"campAuditDrain":"championAuditDrain");
|
||||
var exported=rows(kind);assertEquals(2,exported.size());assertEquals(java.util.Set.of("Original","Retry"),exported.stream().map(row->row.getAsJsonObject("data").get("site_name").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(fixture.directory.snapshot().sites().stream().map(site->site.id().toString()).collect(java.util.stream.Collectors.toSet()),exported.stream().map(row->row.getAsJsonObject("data").get("site_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertTrue(fixture.spawned.isEmpty());
|
||||
}
|
||||
}
|
||||
private java.util.List<com.google.gson.JsonObject> rows(String kind)throws Exception{
|
||||
return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.site.registered")
|
||||
&&row.getAsJsonObject("data").get("site_kind").getAsString().equals(kind)).toList();
|
||||
}
|
||||
private java.util.List<com.google.gson.JsonObject> rows()throws Exception{
|
||||
return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.site.registered")
|
||||
&&row.getAsJsonObject("data").get("site_kind").getAsString().equals("champion")).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.persistence.GearSettings;
|
||||
import games.dmg.fantasystick.shop.ArmorMarket;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ArmorConfigurationEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void everyRegisteredGearReadExportsExactNextRestockPolicyNotDeniedOrInvalidCommands() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
var catalog = market.current().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var settings = (GearSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "gearSettings");
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var denied = new EquipmentAdminCommandNativeTest.Sender(); denied.allowed = false;
|
||||
f.adminCommand.execute(denied.port, "fstickadmin", new String[]{"config", "gear-price-iron"});
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "gear-price-variance-percent", "101"});
|
||||
assertEquals(catalog, market.current().toCompletableFuture().get());
|
||||
for (var key : GearSettings.keys().stream().sorted().toList()) {
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", key});
|
||||
assertTrue(sender.messages.stream().anyMatch(message -> message.startsWith(key + "=" + settings.value(key))));
|
||||
}
|
||||
for (int attempt = 0; attempt < 100 && rows("config.read").size() < GearSettings.keys().size(); attempt++) Thread.sleep(10);
|
||||
assertEquals(GearSettings.keys().size(), rows("config.read").size(), "Every actual registered gear read needs one owner JSONL record");
|
||||
for (var row : rows("config.read")) {
|
||||
var values = row.getAsJsonObject("data"); var key = values.get("key").getAsString();
|
||||
assertTrue(GearSettings.keys().contains(key)); assertEquals(settings.value(key), values.get("after").getAsString());
|
||||
assertEquals("next_armor_restock", values.get("application").getAsString()); assertEquals("console", values.get("actor_kind").getAsString());
|
||||
assertTrue(values.has("application_scope"), "Approved gear configuration events must include their next-catalog application scope");
|
||||
assertEquals("next_catalog", values.get("application_scope").getAsString());
|
||||
assertEquals("acknowledged", values.get("outcome").getAsString()); assertFalse(values.has("before"));
|
||||
}
|
||||
assertTrue(rows("config.changed").isEmpty()); assertEquals(catalog, market.current().toCompletableFuture().get());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void queuedPublishedGearChangesUseActualBeforeValueAndLockedSaveCannotClaimSuccessOrRestock() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
var catalog = market.current().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var settings = (GearSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "gearSettings");
|
||||
var initial = settings.value(GearSettings.VARIANCE);
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "\nus054-unrelated: retain-me\n", java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE, "5"});
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE, "6"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Saved " + GearSettings.VARIANCE + "=6")));
|
||||
assertEquals("6", settings.value(GearSettings.VARIANCE)); assertEquals(catalog, market.current().toCompletableFuture().get());
|
||||
for (int attempt = 0; attempt < 100 && rows("config.changed").size() < 2; attempt++) Thread.sleep(10);
|
||||
assertEquals(2, rows("config.changed").size(), "Only acknowledged native policy publications need configuration change records");
|
||||
var first = rows("config.changed").stream().map(row -> row.getAsJsonObject("data")).filter(values -> values.get("after").getAsString().equals("5")).findFirst().orElseThrow();
|
||||
var second = rows("config.changed").stream().map(row -> row.getAsJsonObject("data")).filter(values -> values.get("after").getAsString().equals("6")).findFirst().orElseThrow();
|
||||
assertEquals(initial, first.get("before").getAsString());
|
||||
assertEquals("5", second.get("before").getAsString(), "Queued history starts at the prior actual native publication, not the request-time snapshot");
|
||||
assertEquals("next_armor_restock", second.get("application").getAsString());
|
||||
assertTrue(second.has("application_scope")); assertEquals("next_catalog", second.get("application_scope").getAsString());
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE, "7"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.contains("Armor market setting save failed; effective settings and the current catalog are unchanged."));
|
||||
assertEquals("6", settings.value(GearSettings.VARIANCE)); assertEquals(catalog, market.current().toCompletableFuture().get());
|
||||
assertEquals(2, rows("config.changed").size());
|
||||
}
|
||||
var yaml = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(data.resolve("config.yml").toFile());
|
||||
assertEquals(6, yaml.getInt(GearSettings.VARIANCE)); assertEquals("retain-me", yaml.getString("us054-unrelated"));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(2, rows("config.changed").size());
|
||||
}
|
||||
|
||||
@Test void rejectedGearReadAndChangeHistoryReplaysWithoutApplyingOldPolicyRestockingOrRepeatingFeedback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
var catalog = market.current().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
ReskillEventsNativeTest.await(f, () -> hasStartupCatalog());
|
||||
var settings = (GearSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "gearSettings"); var initial = settings.value(GearSettings.VARIANCE);
|
||||
((games.dmg.fantasystick.events.LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE});
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE, "5"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Saved " + GearSettings.VARIANCE + "=5")));
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE});
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE, "6"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Saved " + GearSettings.VARIANCE + "=6")));
|
||||
assertEquals("6", settings.value(GearSettings.VARIANCE)); assertEquals(catalog, market.current().toCompletableFuture().get());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var intents = new java.util.ArrayList<java.util.Properties>();
|
||||
try (var files = java.nio.file.Files.list(data.resolve("armor-event-intents"))) {
|
||||
for (var path : files.filter(value -> value.getFileName().toString().endsWith(".pending")).toList()) {
|
||||
var properties = new java.util.Properties(); try (var input = java.nio.file.Files.newInputStream(path)) { properties.load(input); } intents.add(properties);
|
||||
}
|
||||
}
|
||||
assertEquals(4, intents.size()); assertTrue(rows("config.read").isEmpty()); assertTrue(rows("config.changed").isEmpty());
|
||||
var bytes = java.nio.file.Files.readAllBytes(data.resolve("config.yml")); var marketBytes = java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml"));
|
||||
var feedback = List.copyOf(sender.messages);
|
||||
f.plugin.setEnabled(true); market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
assertEquals(catalog, market.current().toCompletableFuture().get());
|
||||
for (int attempt = 0; attempt < 100 && rows("config.read").size() + rows("config.changed").size() < 4; attempt++) Thread.sleep(10);
|
||||
assertEquals(2, rows("config.read").size()); assertEquals(2, rows("config.changed").size());
|
||||
assertArrayEquals(bytes, java.nio.file.Files.readAllBytes(data.resolve("config.yml"))); assertArrayEquals(marketBytes, java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml")));
|
||||
assertEquals(feedback, sender.messages); assertEquals("6", ((GearSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "gearSettings")).value(GearSettings.VARIANCE));
|
||||
var all = new java.util.ArrayList<>(rows("config.read")); all.addAll(rows("config.changed"));
|
||||
for (var intent : intents) {
|
||||
var replay = all.stream().filter(row -> row.get("id").getAsString().equals(intent.getProperty("id"))).findFirst().orElseThrow();
|
||||
assertEquals(intent.getProperty("time"), replay.get("time").getAsString()); assertEquals(intent.getProperty("action"), replay.getAsJsonObject("data").get("action_id").getAsString());
|
||||
}
|
||||
assertTrue(rows("config.read").stream().anyMatch(row -> row.getAsJsonObject("data").get("after").getAsString().equals(initial)));
|
||||
var changed = rows("config.changed").stream().map(row -> row.getAsJsonObject("data")).filter(values -> values.get("after").getAsString().equals("6")).findFirst().orElseThrow();
|
||||
assertEquals("5", changed.get("before").getAsString()); assertEquals("next_armor_restock", changed.get("application").getAsString());
|
||||
assertEquals("next_catalog", changed.get("application_scope").getAsString());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
private boolean hasStartupCatalog() {
|
||||
try { return ReskillEventsNativeTest.records(data).stream().anyMatch(row -> row.get("type").getAsString().endsWith("armor.catalog_refreshed")); }
|
||||
catch (Exception failure) { throw new AssertionError("Cannot parse actual armor exports", failure); }
|
||||
}
|
||||
|
||||
private List<JsonObject> rows(String type) throws Exception {
|
||||
return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith(type))
|
||||
.filter(row -> row.getAsJsonObject("data").has("key")
|
||||
&& row.getAsJsonObject("data").get("key").getAsString().startsWith("gear-")).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.persistence.GearSlot;
|
||||
import games.dmg.fantasystick.persistence.GearTier;
|
||||
import games.dmg.fantasystick.shop.ArmorCatalog;
|
||||
import games.dmg.fantasystick.shop.ArmorMarket;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ArmorEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void startupAndRegisteredRestockExportOneCompleteSharedCatalogButRetainedRestartDoesNotRefresh() throws Exception {
|
||||
ArmorCatalog original, replacement;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
original = market.current().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(18, original.products().size());
|
||||
for (int attempt = 0; attempt < 100 && catalogs().isEmpty(); attempt++) Thread.sleep(10);
|
||||
assertEquals(1, catalogs().size(), "A saved complete shared armor catalog must reach its owner JSONL record");
|
||||
verifyCatalog(original, catalogs().getFirst());
|
||||
assertEquals(original, market.current().toCompletableFuture().get());
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "restock"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.contains("Restocked all Fantasy Armorers"))
|
||||
&& catalogs().size() == 2);
|
||||
replacement = market.current().toCompletableFuture().get();
|
||||
assertNotEquals(original.revision(), replacement.revision());
|
||||
verifyCatalog(replacement, catalogs().stream().filter(row -> row.getAsJsonObject("data")
|
||||
.get("catalog_revision").getAsString().equals(replacement.revision().toString())).findFirst().orElseThrow());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var restarted = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
assertEquals(replacement, restarted.current().toCompletableFuture().get());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
assertEquals(2, catalogs().size(), "A retained-current restart/read cannot invent a new refresh");
|
||||
}
|
||||
|
||||
@Test void rejectedRegisteredRestockReplaysOriginalCatalogWithoutRestockingOrRepeatingFeedback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
var initial = market.current().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
ReskillEventsNativeTest.await(f, () -> catalogs().size() == 1);
|
||||
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "restock"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.contains("Restocked all Fantasy Armorers")));
|
||||
var committed = market.current().toCompletableFuture().get();
|
||||
assertNotEquals(initial.revision(), committed.revision());
|
||||
var feedback = List.copyOf(sender.messages);
|
||||
var bytes = java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml"));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var pending = new java.util.Properties();
|
||||
try (var files = java.nio.file.Files.list(data.resolve("armor-event-intents"))) {
|
||||
var retained = files.filter(path -> path.getFileName().toString().endsWith(".pending")).toList();
|
||||
assertEquals(1, retained.size());
|
||||
try (var input = java.nio.file.Files.newInputStream(retained.getFirst())) { pending.load(input); }
|
||||
}
|
||||
assertEquals(1, catalogs().size(), "Rejected delivery is not an exported restock record");
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
ReskillEventsNativeTest.await(f, () -> catalogs().size() == 2);
|
||||
var restarted = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
assertEquals(committed, restarted.current().toCompletableFuture().get());
|
||||
assertArrayEquals(bytes, java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml")), "Replay cannot restock or rewrite the market");
|
||||
assertEquals(feedback, sender.messages, "Replay cannot deliver administration feedback again");
|
||||
var replay = catalogs().stream().filter(row -> row.get("id").getAsString().equals(pending.getProperty("id")))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals(pending.getProperty("time"), replay.get("time").getAsString());
|
||||
assertEquals(pending.getProperty("action"), replay.getAsJsonObject("data").get("action_id").getAsString());
|
||||
verifyCatalog(committed, replay);
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(2, catalogs().size());
|
||||
}
|
||||
|
||||
@Test void registeredPlacementAndRemovalExportActualSavedRevisionAndLoadedEntity() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(admin.core.id, admin.player); doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
|
||||
var anchor = admin.core.location.clone();
|
||||
int x = anchor.getBlockX() >> 4, z = anchor.getBlockZ() >> 4;
|
||||
var npc = mock(org.bukkit.entity.Villager.class); var entityId = java.util.UUID.randomUUID();
|
||||
when(npc.getUniqueId()).thenReturn(entityId); when(npc.getLocation()).thenReturn(anchor);
|
||||
var tags = new java.util.HashMap<org.bukkit.NamespacedKey, String>();
|
||||
var pdc = mock(org.bukkit.persistence.PersistentDataContainer.class);
|
||||
when(npc.getPersistentDataContainer()).thenReturn(pdc);
|
||||
doAnswer(call -> { tags.put(call.getArgument(0), call.getArgument(2)); return null; }).when(pdc)
|
||||
.set(any(org.bukkit.NamespacedKey.class), eq(org.bukkit.persistence.PersistentDataType.STRING), anyString());
|
||||
when(pdc.get(any(org.bukkit.NamespacedKey.class), eq(org.bukkit.persistence.PersistentDataType.STRING)))
|
||||
.thenAnswer(call -> tags.get(call.getArgument(0)));
|
||||
var present = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
var chunk = mock(org.bukkit.Chunk.class);
|
||||
when(chunk.getWorld()).thenReturn(admin.world); when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z);
|
||||
when(chunk.getEntities()).thenAnswer(call -> present.get() ? new org.bukkit.entity.Entity[]{npc} : new org.bukkit.entity.Entity[0]);
|
||||
when(admin.world.getEntities()).thenAnswer(call -> present.get() ? List.of(npc) : List.of());
|
||||
when(admin.world.isChunkLoaded(x, z)).thenReturn(true); when(admin.world.getChunkAt(x, z)).thenReturn(chunk);
|
||||
doAnswer(call -> { java.util.function.Consumer<org.bukkit.entity.Villager> configure = call.getArgument(2);
|
||||
configure.accept(npc); present.set(true); return npc; }).when(admin.world)
|
||||
.spawn(any(org.bukkit.Location.class), eq(org.bukkit.entity.Villager.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<? super org.bukkit.entity.Villager>>any());
|
||||
doAnswer(call -> { present.set(false); return null; }).when(npc).remove();
|
||||
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"shop", "place", "Revision Forge"});
|
||||
admin.core.location.add(20, 0, 20); // Audit must retain registered input, not recapture the moved administrator.
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && present.get());
|
||||
var shop = directory.all().getFirst();
|
||||
assertNotNull(shop.revision()); assertEquals(List.of(shop), new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(data).load());
|
||||
for (int attempt = 0; attempt < 100 && rows("armorer.placed").isEmpty(); attempt++) Thread.sleep(10);
|
||||
assertEquals(1, rows("armorer.placed").size(), "Durable registration and actual native materialization need an owner record");
|
||||
var placed = rows("armorer.placed").getFirst(); var values = placed.getAsJsonObject("data");
|
||||
assertEquals(shop.id().toString(), values.get("armorer_id").getAsString());
|
||||
assertEquals(shop.revision().toString(), values.get("armorer_revision").getAsString());
|
||||
assertEquals("persisted", values.get("revision_capture_quality").getAsString());
|
||||
assertEquals(entityId.toString(), values.get("entity_id").getAsString());
|
||||
assertEquals("materialized", values.get("entity_outcome").getAsString());
|
||||
assertEquals(admin.core.id.toString(), placed.get("playerid").getAsString());
|
||||
assertEquals(shop.world().toString(), values.get("world_id").getAsString());
|
||||
assertEquals(anchor.getX(), values.get("x").getAsDouble()); assertEquals(anchor.getY(), values.get("y").getAsDouble());
|
||||
assertEquals(anchor.getZ(), values.get("z").getAsDouble()); assertEquals(anchor.getYaw(), values.get("yaw").getAsDouble());
|
||||
assertEquals(anchor.getPitch(), values.get("pitch").getAsDouble());
|
||||
f.dispatcher.accept(new org.bukkit.event.world.ChunkLoadEvent(chunk, false));
|
||||
verify(admin.world, times(1)).spawn(any(org.bukkit.Location.class), eq(org.bukkit.entity.Villager.class),
|
||||
org.mockito.ArgumentMatchers.<java.util.function.Consumer<? super org.bukkit.entity.Villager>>any());
|
||||
assertEquals(1, rows("armorer.placed").size(), "Loaded-chunk reconciliation is not another durable registration");
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "remove", shop.id().toString()});
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().isEmpty() && !present.get() && rows("armorer.removed").size() == 1);
|
||||
var removed = rows("armorer.removed").getFirst().getAsJsonObject("data");
|
||||
assertEquals(shop.revision().toString(), removed.get("armorer_revision").getAsString());
|
||||
assertEquals(entityId.toString(), removed.get("entity_id").getAsString());
|
||||
assertEquals("removed", removed.get("entity_outcome").getAsString());
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "remove", shop.id().toString()});
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(1, rows("armorer.placed").size()); assertEquals(1, rows("armorer.removed").size());
|
||||
}
|
||||
|
||||
@Test void registeredShopPurchaseExportsExactStockCoinsTierAndReceiptNotDuplicateClicksOrRendering() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var ports = npc(f); var player = ports.player();
|
||||
var old = player.core.record; var balance = java.math.BigInteger.ONE.shiftLeft(100);
|
||||
var funded = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.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(), balance, true,
|
||||
old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(funded, old.revision());
|
||||
f.activate(player);
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Purchase Forge"});
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && ports.present().get());
|
||||
var shop = directory.all().getFirst();
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var before = storage.find(old.id()).get(); assertTrue(before.isPresent()); var previous = before.orElseThrow();
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
var catalog = market.current().toCompletableFuture().get();
|
||||
var product = new games.dmg.fantasystick.shop.ArmorProduct(GearSlot.CHESTPLATE, GearTier.IRON);
|
||||
var offer = catalog.offer(product);
|
||||
var session = ((games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"))
|
||||
.active(player.core.id).orElseThrow().session();
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(player.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
ReskillEventsNativeTest.await(f, () -> !player.inventories.isEmpty());
|
||||
var menu = player.inventories.getLast();
|
||||
var view = mock(org.bukkit.inventory.InventoryView.class);
|
||||
when(view.getTopInventory()).thenReturn(menu); when(view.getBottomInventory()).thenReturn(player.core.inventory);
|
||||
when(view.getPlayer()).thenReturn(player.player); when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST);
|
||||
when(view.getItem(27)).thenReturn(menu.getItem(27)); when(view.getInventory(27)).thenReturn(menu); when(view.convertSlot(27)).thenReturn(27);
|
||||
var click = new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,
|
||||
27, org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
|
||||
f.dispatcher.accept(click); assertTrue(click.isCancelled());
|
||||
ReskillEventsNativeTest.await(f, () -> storage.find(old.id()).join().orElseThrow().gear().chestplate() == GearTier.IRON
|
||||
&& player.core.inventory.getChestplate() != null && player.core.inventory.getChestplate().getType() == org.bukkit.Material.IRON_CHESTPLATE);
|
||||
var saved = storage.find(old.id()).get().orElseThrow();
|
||||
assertEquals(balance.subtract(offer.price()), saved.coins()); assertTrue(player.core.inventory.getChestplate().getItemMeta().isUnbreakable());
|
||||
var afterCatalog = market.current().toCompletableFuture().get();
|
||||
assertEquals(offer.remaining() - 1, afterCatalog.offer(product).remaining());
|
||||
var receipts = new java.util.HashSet<>(saved.rewardReceipts()); receipts.removeAll(previous.rewardReceipts()); assertEquals(1, receipts.size());
|
||||
for (int attempt = 0; attempt < 100 && rows("armor.purchased").isEmpty(); attempt++) Thread.sleep(10);
|
||||
assertEquals(1, rows("armor.purchased").size(), "Acknowledged stock, coin and gear mutation must reach one purchase owner record");
|
||||
var row = rows("armor.purchased").getFirst(); var values = row.getAsJsonObject("data");
|
||||
assertEquals(receipts.iterator().next().toString(), values.get("receipt_id").getAsString());
|
||||
assertEquals(shop.id().toString(), values.get("armorer_id").getAsString());
|
||||
assertEquals(catalog.revision().toString(), values.get("catalog_revision").getAsString());
|
||||
assertEquals(player.core.id.toString(), row.get("playerid").getAsString()); assertEquals(old.id().toString(), row.get("characterid").getAsString());
|
||||
assertEquals(session.toString(), values.get("session_id").getAsString());
|
||||
assertEquals("chestplate.iron", values.get("product_id").getAsString());
|
||||
assertEquals("none", values.get("previous_tier").getAsString()); assertEquals("iron", values.get("new_tier").getAsString());
|
||||
assertEquals(offer.price().toString(), values.get("price").getAsString());
|
||||
assertEquals(balance.toString(), values.get("balance_before").getAsString()); assertEquals(saved.coins().toString(), values.get("balance_after").getAsString());
|
||||
assertEquals(Integer.toString(offer.remaining()), values.get("stock_before").getAsString());
|
||||
assertEquals(Integer.toString(offer.remaining() - 1), values.get("stock_after").getAsString());
|
||||
assertEquals(previous.revision().toString(), values.get("before_revision").getAsString()); assertEquals(saved.revision().toString(), values.get("after_revision").getAsString());
|
||||
assertEquals(ports.anchor().getX(), values.getAsJsonObject("location").get("x").getAsDouble());
|
||||
var duplicate = new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,
|
||||
27, org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
|
||||
f.dispatcher.accept(duplicate); assertTrue(duplicate.isCancelled()); market.current().toCompletableFuture().get(); PartyBookNativeTest.drain(f);
|
||||
assertEquals(saved, storage.find(old.id()).get().orElseThrow());
|
||||
PartyBookNativeTest.open(f, player); assertEquals(1, rows("armor.purchased").size());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(1, rows("armor.purchased").size());
|
||||
}
|
||||
|
||||
@Test void realSharedMarketMidnightPublishesOneCompleteScheduledCatalogNotPerProductRecords() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
var initial = market.current().toCompletableFuture().get();
|
||||
ReskillEventsNativeTest.await(f, () -> catalogs().size() == 1);
|
||||
var field = ArmorMarket.class.getDeclaredField("clock"); field.setAccessible(true);
|
||||
field.set(market, java.time.Clock.fixed(initial.day().plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant(), java.time.ZoneOffset.UTC));
|
||||
var next = market.current().toCompletableFuture().get();
|
||||
assertEquals(initial.day().plusDays(1), next.day()); assertNotEquals(initial.revision(), next.revision());
|
||||
assertEquals(ArmorCatalog.RefreshReason.SCHEDULED, next.reason());
|
||||
ReskillEventsNativeTest.await(f, () -> catalogs().size() == 2);
|
||||
verifyCatalog(next, catalogs().stream().filter(row -> row.getAsJsonObject("data").get("catalog_revision").getAsString().equals(next.revision().toString())).findFirst().orElseThrow());
|
||||
assertEquals(next, market.current().toCompletableFuture().get());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(2, catalogs().size());
|
||||
}
|
||||
|
||||
@Test void rejectedRealPurchaseReplaysExactReceiptOfflineWithoutChargingEquippingRestockingOrFeedback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var ports = npc(f); var player = ports.player(); var old = player.core.record;
|
||||
var balance = java.math.BigInteger.ONE.shiftLeft(100);
|
||||
var funded = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.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(), balance, true,
|
||||
old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(funded, old.revision()); f.activate(player);
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Offline Receipt Forge"});
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && ports.present().get() && rows("armorer.placed").size() == 1 && catalogs().size() == 1);
|
||||
var shop = directory.all().getFirst();
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket"); var catalog = market.current().toCompletableFuture().get();
|
||||
var product = new games.dmg.fantasystick.shop.ArmorProduct(GearSlot.CHESTPLATE, GearTier.IRON); var offer = catalog.offer(product);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var previous = storage.find(old.id()).get().orElseThrow();
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(player.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
ReskillEventsNativeTest.await(f, () -> !player.inventories.isEmpty()); var menu = player.inventories.getLast();
|
||||
var view = mock(org.bukkit.inventory.InventoryView.class); when(view.getTopInventory()).thenReturn(menu);
|
||||
when(view.getBottomInventory()).thenReturn(player.core.inventory); when(view.getPlayer()).thenReturn(player.player);
|
||||
when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST); when(view.getItem(27)).thenReturn(menu.getItem(27));
|
||||
when(view.getInventory(27)).thenReturn(menu); when(view.convertSlot(27)).thenReturn(27);
|
||||
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,
|
||||
27, org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
|
||||
ReskillEventsNativeTest.await(f, () -> player.messages.contains("Purchased Iron Chestplate.")
|
||||
&& player.core.inventory.getChestplate() != null && player.core.inventory.getChestplate().getType() == org.bukkit.Material.IRON_CHESTPLATE);
|
||||
var saved = storage.find(old.id()).get().orElseThrow(); assertEquals(balance.subtract(offer.price()), saved.coins());
|
||||
assertEquals(GearTier.IRON, saved.gear().chestplate()); assertEquals(offer.remaining() - 1, market.current().toCompletableFuture().get().offer(product).remaining());
|
||||
var receipts = new java.util.HashSet<>(saved.rewardReceipts()); receipts.removeAll(previous.rewardReceipts()); assertEquals(1, receipts.size());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var pending = new java.util.Properties();
|
||||
try (var files = java.nio.file.Files.list(data.resolve("armor-event-intents"))) {
|
||||
var intents = files.filter(path -> path.getFileName().toString().endsWith(".pending")).toList(); assertEquals(1, intents.size());
|
||||
try (var input = java.nio.file.Files.newInputStream(intents.getFirst())) { pending.load(input); }
|
||||
}
|
||||
assertTrue(rows("armor.purchased").isEmpty());
|
||||
var marketBytes = java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml"));
|
||||
var registrationBytes = java.nio.file.Files.readAllBytes(data.resolve("fantasy-armorers.yml"));
|
||||
var character = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(old.id()).orElseThrow();
|
||||
var feedback = List.copyOf(player.messages); var inventory = player.core.snapshot();
|
||||
f.players.clear(); player.core.online = false; clearInvocations(player.player); clearInvocations(player.world); clearInvocations(ports.entity());
|
||||
f.plugin.setEnabled(true); ready(f); ReskillEventsNativeTest.await(f, () -> rows("armor.purchased").size() == 1);
|
||||
assertEquals(character, new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(old.id()).orElseThrow());
|
||||
assertArrayEquals(marketBytes, java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml")));
|
||||
assertArrayEquals(registrationBytes, java.nio.file.Files.readAllBytes(data.resolve("fantasy-armorers.yml")));
|
||||
assertArrayEquals(inventory, player.core.snapshot()); assertEquals(feedback, player.messages);
|
||||
verifyNoInteractions(player.player, player.world, ports.entity());
|
||||
var row = rows("armor.purchased").getFirst(); var values = row.getAsJsonObject("data");
|
||||
assertEquals(pending.getProperty("id"), row.get("id").getAsString()); assertEquals(pending.getProperty("time"), row.get("time").getAsString());
|
||||
assertEquals(pending.getProperty("action"), values.get("action_id").getAsString());
|
||||
assertEquals(receipts.iterator().next().toString(), values.get("receipt_id").getAsString());
|
||||
assertEquals(shop.id().toString(), values.get("armorer_id").getAsString()); assertEquals(catalog.revision().toString(), values.get("catalog_revision").getAsString());
|
||||
assertEquals(balance.toString(), values.get("balance_before").getAsString()); assertEquals(saved.coins().toString(), values.get("balance_after").getAsString());
|
||||
assertEquals(previous.revision().toString(), values.get("before_revision").getAsString()); assertEquals(saved.revision().toString(), values.get("after_revision").getAsString());
|
||||
assertEquals("none", values.get("previous_tier").getAsString()); assertEquals("iron", values.get("new_tier").getAsString());
|
||||
assertEquals(Integer.toString(offer.remaining()), values.get("stock_before").getAsString()); assertEquals(Integer.toString(offer.remaining()-1), values.get("stock_after").getAsString());
|
||||
f.players.put(player.core.id, player.player); player.core.online = true;
|
||||
f.reenter(player); // Restored markers need a fresh slot observation, not another unchanged-marker request.
|
||||
assertEquals(org.bukkit.Material.IRON_CHESTPLATE, player.core.inventory.getChestplate().getType());
|
||||
assertTrue(player.core.inventory.getChestplate().getItemMeta().isUnbreakable());
|
||||
assertEquals(saved.coins(), new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(old.id()).orElseThrow().coins());
|
||||
assertEquals(1, rows("armor.purchased").size(), "Reloading permanent native gear is not another purchase");
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
assertEquals(1, rows("armor.purchased").size());
|
||||
}
|
||||
|
||||
@Test void registeredPlacementSaveAndNativeSpawnFailuresCannotExportSuccessOrLeaveRegistration() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var player = ports.player();
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
java.nio.file.Files.createDirectory(data.resolve("fantasy-armorers.yml"));
|
||||
java.nio.file.Files.writeString(data.resolve("fantasy-armorers.yml/block-save"), "nonempty directory must not be replaced");
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Unsaved Forge"});
|
||||
try { ReskillEventsNativeTest.await(f, () -> player.messages.contains("Fantasy Armorer placement failed; no registration or entity changed.")); }
|
||||
catch (AssertionError failure) { throw new AssertionError("Expected failed save: messages=" + player.messages + "; registrations=" + directory.all(), failure); }
|
||||
assertTrue(directory.all().isEmpty()); assertFalse(ports.present().get()); assertTrue(rows("armorer.placed").isEmpty());
|
||||
verify(player.world, never()).spawn(any(org.bukkit.Location.class), eq(org.bukkit.entity.Villager.class),
|
||||
org.mockito.ArgumentMatchers.<java.util.function.Consumer<? super org.bukkit.entity.Villager>>any());
|
||||
java.nio.file.Files.delete(data.resolve("fantasy-armorers.yml/block-save"));
|
||||
java.nio.file.Files.delete(data.resolve("fantasy-armorers.yml")); player.messages.clear();
|
||||
doThrow(new IllegalStateException("Injected native Armorer spawn failure")).when(player.world).spawn(any(org.bukkit.Location.class),
|
||||
eq(org.bukkit.entity.Villager.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<? super org.bukkit.entity.Villager>>any());
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Unmaterialized Forge"});
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().isEmpty()
|
||||
&& player.messages.contains("Fantasy Armorer placement failed; no registration or entity changed."));
|
||||
assertTrue(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(data).load().isEmpty());
|
||||
assertFalse(ports.present().get()); assertTrue(rows("armorer.placed").isEmpty());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertTrue(rows("armorer.placed").isEmpty());
|
||||
}
|
||||
|
||||
@Test void registeredNativeRemovalFailureRestoresExactRevisionAndCannotExportRemovedSuccess() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var player = ports.player();
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Rollback Forge"});
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && ports.present().get() && rows("armorer.placed").size() == 1);
|
||||
var saved = directory.all().getFirst(); var bytes = java.nio.file.Files.readAllBytes(data.resolve("fantasy-armorers.yml"));
|
||||
doThrow(new IllegalStateException("Injected native Armorer removal failure")).when(ports.entity()).remove();
|
||||
try {
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "remove", saved.id().toString()});
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().equals(List.of(saved))
|
||||
&& player.messages.contains("Fantasy Armorer removal failed; registration and entity are unchanged."));
|
||||
assertArrayEquals(bytes, java.nio.file.Files.readAllBytes(data.resolve("fantasy-armorers.yml")));
|
||||
assertTrue(ports.present().get()); assertEquals(saved.revision(), directory.all().getFirst().revision());
|
||||
assertTrue(rows("armorer.removed").isEmpty()); assertEquals(1, rows("armorer.placed").size());
|
||||
} finally { doAnswer(call -> { ports.present().set(false); return null; }).when(ports.entity()).remove(); }
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertTrue(rows("armorer.removed").isEmpty());
|
||||
}
|
||||
|
||||
@Test void realConcurrentViewsCannotOversellOrClaimCancelledSoldOutStaleAndClosedPurchasesWithFeedbackOff() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var first = ports.player();
|
||||
var second = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(second.core.id, second.player);
|
||||
second.core.location.setWorld(first.world);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
for (var key : List.of("gear-stock-iron-min", "gear-stock-iron-max")) {
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", key, "1"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Saved " + key + "=1")));
|
||||
}
|
||||
f.adminCommand.execute(first.player, "fstickadmin", new String[]{"shop", "place", "Shared Last Item Forge"});
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && ports.present().get());
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "restock"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Restocked all Fantasy Armorers")));
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket"); var catalog = market.current().toCompletableFuture().get();
|
||||
var product = new games.dmg.fantasystick.shop.ArmorProduct(GearSlot.CHESTPLATE, GearTier.IRON); assertEquals(1, catalog.offer(product).remaining());
|
||||
// Ordinary/native-cancelled interactions are not purchase authorization or another menu.
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(first.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
assertTrue(first.inventories.isEmpty()); assertTrue(rows("armor.purchased").isEmpty());
|
||||
var balance = java.math.BigInteger.ONE.shiftLeft(100); fund(first, balance); fund(second, balance); f.activate(first); f.activate(second);
|
||||
f.feedbackCommand.execute(first.player, "fstickfeedback", new String[]{"off"});
|
||||
var cancelled = new org.bukkit.event.player.PlayerInteractEntityEvent(first.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
cancelled.setCancelled(true); f.dispatcher.accept(cancelled); assertTrue(first.inventories.isEmpty());
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(first.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(second.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
ReskillEventsNativeTest.await(f, () -> !first.inventories.isEmpty() && !second.inventories.isEmpty());
|
||||
var firstView = purchaseView(first, first.inventories.getLast()); var secondView = purchaseView(second, second.inventories.getLast());
|
||||
var veto = purchaseClick(firstView); veto.setCancelled(true); f.dispatcher.accept(veto); assertTrue(rows("armor.purchased").isEmpty());
|
||||
f.dispatcher.accept(purchaseClick(firstView)); f.dispatcher.accept(purchaseClick(secondView));
|
||||
ReskillEventsNativeTest.await(f, () -> first.messages.contains("Purchased Iron Chestplate.") && second.messages.contains("Armor purchase refused: sold out.") && rows("armor.purchased").size() == 1);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var winner = storage.find(first.core.record.id()).get().orElseThrow(); var loser = storage.find(second.core.record.id()).get().orElseThrow();
|
||||
assertEquals(balance.subtract(catalog.offer(product).price()), winner.coins()); assertEquals(GearTier.IRON, winner.gear().chestplate());
|
||||
assertEquals(balance, loser.coins()); assertEquals(GearTier.NONE, loser.gear().chestplate()); assertEquals(0, market.current().toCompletableFuture().get().offer(product).remaining());
|
||||
assertEquals(first.core.id.toString(), rows("armor.purchased").getFirst().get("playerid").getAsString());
|
||||
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryCloseEvent(firstView));
|
||||
assertEquals(winner, storage.find(first.core.record.id()).get().orElseThrow()); assertEquals(1, rows("armor.purchased").size());
|
||||
sender.messages.clear(); f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "restock"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Restocked all Fantasy Armorers")));
|
||||
var current = market.current().toCompletableFuture().get();
|
||||
f.dispatcher.accept(purchaseClick(secondView));
|
||||
ReskillEventsNativeTest.await(f, () -> second.messages.contains("Armor purchase refused: stale."));
|
||||
assertEquals(loser, storage.find(second.core.record.id()).get().orElseThrow()); assertEquals(current, market.current().toCompletableFuture().get());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(1, rows("armor.purchased").size());
|
||||
}
|
||||
@Test void failedRegisteredCatalogSaveCannotRefreshOrMutateAnySharedOffer() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket"); var original = market.current().toCompletableFuture().get();
|
||||
ReskillEventsNativeTest.await(f, () -> catalogs().size() == 1);
|
||||
var path = data.resolve("armor-catalog.yml"); var backup = data.resolve("armor-catalog.saved-test");
|
||||
var bytes = java.nio.file.Files.readAllBytes(path); java.nio.file.Files.move(path, backup); java.nio.file.Files.createDirectory(path);
|
||||
java.nio.file.Files.writeString(path.resolve("block-save"), "nonreplaceable save target");
|
||||
try {
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "restock"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.contains("Fantasy Armorer restock failed; the prior catalog is unchanged."));
|
||||
assertEquals(original, market.current().toCompletableFuture().get()); assertEquals(1, catalogs().size());
|
||||
assertFalse(sender.messages.stream().anyMatch(message -> message.startsWith("Restocked all Fantasy Armorers")));
|
||||
assertArrayEquals(bytes, java.nio.file.Files.readAllBytes(backup));
|
||||
} finally { java.nio.file.Files.delete(path.resolve("block-save")); java.nio.file.Files.delete(path); java.nio.file.Files.move(backup, path); }
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(1, catalogs().size());
|
||||
}
|
||||
|
||||
@Test void realPlanAndAtomicCoinGearSaveFailuresCannotClaimPurchaseAndRetainRecoverablePlan() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var player = ports.player(); fund(player, java.math.BigInteger.ONE.shiftLeft(100)); f.activate(player);
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Store Fault Forge"});
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && ports.present().get());
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var before = storage.find(player.core.record.id()).get().orElseThrow();
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket"); var catalog = market.current().toCompletableFuture().get();
|
||||
var product = new games.dmg.fantasystick.shop.ArmorProduct(GearSlot.CHESTPLATE, GearTier.IRON);
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(player.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
ReskillEventsNativeTest.await(f, () -> !player.inventories.isEmpty()); var view = purchaseView(player, player.inventories.getLast());
|
||||
var nativeBefore = player.core.snapshot();
|
||||
java.nio.file.Files.writeString(data.resolve("armor-purchase-operations"), "Block required operation directory");
|
||||
try {
|
||||
f.dispatcher.accept(purchaseClick(view));
|
||||
ReskillEventsNativeTest.await(f, () -> player.messages.contains("Armor purchase refused: failed."));
|
||||
assertEquals(before, storage.find(before.id()).get().orElseThrow()); assertEquals(catalog, market.current().toCompletableFuture().get());
|
||||
assertArrayEquals(nativeBefore, player.core.snapshot()); assertTrue(rows("armor.purchased").isEmpty());
|
||||
} finally { java.nio.file.Files.delete(data.resolve("armor-purchase-operations")); }
|
||||
player.messages.clear();
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"), java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid()); f.dispatcher.accept(purchaseClick(view));
|
||||
ReskillEventsNativeTest.await(f, () -> player.messages.contains("Armor purchase refused: failed."));
|
||||
assertEquals(before, storage.find(before.id()).get().orElseThrow()); assertArrayEquals(nativeBefore, player.core.snapshot()); assertTrue(rows("armor.purchased").isEmpty());
|
||||
var reserved = market.current().toCompletableFuture().get(); assertEquals(catalog.revision(), reserved.revision());
|
||||
assertEquals(1, reserved.transactions().size()); assertEquals(catalog.offer(product).remaining()-1, reserved.offer(product).remaining());
|
||||
var operation = reserved.transactions().keySet().iterator().next();
|
||||
assertEquals(ArmorCatalog.TransactionState.RESERVED, reserved.transactions().get(operation).state());
|
||||
try (var files = java.nio.file.Files.list(data.resolve("armor-purchase-operations"))) {
|
||||
var plans = files.filter(path -> path.getFileName().toString().endsWith(".yml")).toList(); assertEquals(1, plans.size());
|
||||
var plan = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(plans.getFirst().toFile());
|
||||
assertEquals(operation.toString(), plan.getString("operation-id")); assertEquals(before.id().toString(), plan.getString("character"));
|
||||
assertEquals(before.revision().toString(), plan.getString("expected-character-revision"));
|
||||
assertEquals(catalog.offer(product).price().toString(), plan.getString("price"));
|
||||
}
|
||||
}
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertTrue(rows("armor.purchased").isEmpty());
|
||||
}
|
||||
|
||||
private void fund(FantasyStickLifecycleNativeTest.Actor actor, java.math.BigInteger balance) throws Exception {
|
||||
var old = actor.core.record;
|
||||
var funded = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.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(), balance, true, old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(funded, old.revision());
|
||||
}
|
||||
private static org.bukkit.inventory.InventoryView purchaseView(FantasyStickLifecycleNativeTest.Actor actor, org.bukkit.inventory.Inventory menu) {
|
||||
var view = mock(org.bukkit.inventory.InventoryView.class); when(view.getTopInventory()).thenReturn(menu); when(view.getBottomInventory()).thenReturn(actor.core.inventory);
|
||||
when(view.getPlayer()).thenReturn(actor.player); when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST);
|
||||
when(view.getItem(27)).thenReturn(menu.getItem(27)); when(view.getInventory(27)).thenReturn(menu); when(view.convertSlot(27)).thenReturn(27); return view;
|
||||
}
|
||||
private static org.bukkit.event.inventory.InventoryClickEvent purchaseClick(org.bukkit.inventory.InventoryView view) {
|
||||
return new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 27,
|
||||
org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
|
||||
}
|
||||
|
||||
private record Npc(FantasyStickLifecycleNativeTest.Actor player, org.bukkit.entity.Villager entity,
|
||||
org.bukkit.Location anchor, java.util.concurrent.atomic.AtomicBoolean present) {}
|
||||
private Npc npc(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
var player = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(player.core.id, player.player); doReturn(true).when(player.player).hasPermission("fantasystick.admin");
|
||||
var anchor = player.core.location.clone(); int x = anchor.getBlockX() >> 4, z = anchor.getBlockZ() >> 4;
|
||||
var entity = mock(org.bukkit.entity.Villager.class); var present = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
when(entity.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(entity.getLocation()).thenReturn(anchor);
|
||||
when(entity.getType()).thenReturn(org.bukkit.entity.EntityType.VILLAGER);
|
||||
var tags = new java.util.HashMap<org.bukkit.NamespacedKey, String>(); var pdc = mock(org.bukkit.persistence.PersistentDataContainer.class);
|
||||
when(entity.getPersistentDataContainer()).thenReturn(pdc);
|
||||
doAnswer(call -> { tags.put(call.getArgument(0), call.getArgument(2)); return null; }).when(pdc)
|
||||
.set(any(org.bukkit.NamespacedKey.class), eq(org.bukkit.persistence.PersistentDataType.STRING), anyString());
|
||||
when(pdc.get(any(org.bukkit.NamespacedKey.class), eq(org.bukkit.persistence.PersistentDataType.STRING))).thenAnswer(call -> tags.get(call.getArgument(0)));
|
||||
var chunk = mock(org.bukkit.Chunk.class); when(chunk.getWorld()).thenReturn(player.world); when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z);
|
||||
when(chunk.getEntities()).thenAnswer(call -> present.get() ? new org.bukkit.entity.Entity[]{entity} : new org.bukkit.entity.Entity[0]);
|
||||
when(player.world.getEntities()).thenAnswer(call -> present.get() ? List.of(entity) : List.of());
|
||||
when(player.world.isChunkLoaded(x, z)).thenReturn(true); when(player.world.getChunkAt(x, z)).thenReturn(chunk);
|
||||
doAnswer(call -> { java.util.function.Consumer<org.bukkit.entity.Villager> configure = call.getArgument(2);
|
||||
configure.accept(entity); present.set(true); return entity; }).when(player.world).spawn(any(org.bukkit.Location.class),
|
||||
eq(org.bukkit.entity.Villager.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<? super org.bukkit.entity.Villager>>any());
|
||||
doAnswer(call -> { present.set(false); return null; }).when(entity).remove();
|
||||
return new Npc(player, entity, anchor, present);
|
||||
}
|
||||
|
||||
private List<JsonObject> rows(String type) {
|
||||
try { return ReskillEventsNativeTest.records(data).stream()
|
||||
.filter(row -> row.get("type").getAsString().endsWith(type)).toList(); }
|
||||
catch (Exception failure) { throw new AssertionError("Cannot parse exported armor evidence", failure); }
|
||||
}
|
||||
|
||||
private static void verifyCatalog(ArmorCatalog catalog, JsonObject row) {
|
||||
var values = row.getAsJsonObject("data");
|
||||
assertEquals(catalog.revision().toString(), values.get("catalog_revision").getAsString());
|
||||
assertEquals(catalog.day().toString(), values.get("catalog_day").getAsString());
|
||||
assertEquals(catalog.reason().name().toLowerCase(Locale.ROOT), values.get("refresh_reason").getAsString());
|
||||
assertEquals(catalog.refreshedAt().toString(), row.get("time").getAsString());
|
||||
assertEquals(catalog.day().plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant().toString(),
|
||||
values.get("next_midnight").getAsString());
|
||||
var products = values.getAsJsonArray("products"); assertEquals(18, products.size());
|
||||
var seen = new java.util.HashSet<String>();
|
||||
for (var element : products) {
|
||||
var item = element.getAsJsonObject(); var id = item.get("product_id").getAsString();
|
||||
assertTrue(seen.add(id), "Each product appears once in the complete publication");
|
||||
var parts = id.split("\\."); assertEquals(2, parts.length);
|
||||
var product = new games.dmg.fantasystick.shop.ArmorProduct(GearSlot.valueOf(parts[0].toUpperCase(Locale.ROOT)),
|
||||
GearTier.valueOf(parts[1].toUpperCase(Locale.ROOT)));
|
||||
var saved = catalog.offer(product);
|
||||
assertEquals(saved.price().toString(), item.get("price").getAsString());
|
||||
assertEquals(Integer.toString(saved.remaining()), item.get("stock").getAsString());
|
||||
}
|
||||
}
|
||||
private static void ready(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"))
|
||||
.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
private List<JsonObject> catalogs() {
|
||||
try { return ReskillEventsNativeTest.records(data).stream()
|
||||
.filter(row -> row.get("type").getAsString().endsWith("armor.catalog_refreshed")).toList(); }
|
||||
catch (Exception failure) { throw new AssertionError("Cannot parse exported armor catalogue evidence", failure); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ArmorMetadataNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
private boolean hasPlacement() {
|
||||
try { return ReskillEventsNativeTest.records(data).stream().anyMatch(row -> row.get("type").getAsString().endsWith("armorer.placed")); }
|
||||
catch (Exception failure) { throw new AssertionError("Cannot parse actual Armorer exports", failure); }
|
||||
}
|
||||
|
||||
@Test void unavailableAuditActorNameCannotInterruptAuthorizedDurableRegistration() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id, actor.player);
|
||||
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
|
||||
var name = actor.player.getName();
|
||||
doThrow(new IllegalStateException("Unavailable audit actor name")).when(actor.player).getName();
|
||||
try {
|
||||
assertDoesNotThrow(() -> f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"shop", "place", "Metadata Forge"}),
|
||||
"Audit actor metadata cannot interrupt an authorized Armorer registration");
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().size() == 1);
|
||||
var registration = directory.all().getFirst();
|
||||
assertEquals(java.util.List.of(registration), new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(data).load());
|
||||
assertNotNull(registration.revision());
|
||||
ReskillEventsNativeTest.await(f, () -> hasPlacement());
|
||||
var row = ReskillEventsNativeTest.records(data).stream()
|
||||
.filter(value -> value.get("type").getAsString().endsWith("armorer.placed")).findFirst().orElseThrow();
|
||||
var values = row.getAsJsonObject("data");
|
||||
assertEquals("system", values.get("actor_kind").getAsString(), "Common writer requires complete player identity for its player context");
|
||||
assertEquals("player", values.get("request_actor_kind").getAsString());
|
||||
assertEquals(actor.core.id.toString(), values.get("request_player_id").getAsString());
|
||||
assertFalse(values.has("request_player_name")); assertFalse(values.has("player_name")); assertFalse(row.has("playerid"));
|
||||
assertEquals("unavailable", values.get("actor_capture_quality").getAsString());
|
||||
assertEquals("unloaded", values.get("chunk_status").getAsString());
|
||||
assertEquals("unloaded", values.get("entity_outcome").getAsString());
|
||||
assertFalse(values.has("entity_id"));
|
||||
verify(actor.world, never()).getChunkAt(anyInt(), anyInt());
|
||||
} finally { doReturn(name).when(actor.player).getName(); }
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository;
|
||||
import games.dmg.fantasystick.shop.FantasyArmorer;
|
||||
import games.dmg.fantasystick.shop.FantasyArmorerDirectory;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ArmorRegistrationReplayNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void realLegacyStartupDoesNotRewriteOrInventRevisionAndRegisteredRemovalReportsUnavailable() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(actor.core.id, actor.player);
|
||||
when(actor.world.getEntities()).thenReturn(List.of());
|
||||
var point = actor.core.location;
|
||||
var legacy = new FantasyArmorer(UUID.randomUUID(), "Legacy Forge", actor.world.getUID(), point.getX(), point.getY(), point.getZ(), point.getYaw(), point.getPitch());
|
||||
var repository = new YamlFantasyArmorerRepository(data); repository.save(List.of(legacy));
|
||||
var bytes = Files.readAllBytes(data.resolve("fantasy-armorers.yml"));
|
||||
f.plugin.setEnabled(true);
|
||||
var directory = (FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
assertEquals(List.of(legacy), directory.all()); assertNull(directory.all().getFirst().revision());
|
||||
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("fantasy-armorers.yml")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "remove", legacy.id().toString()});
|
||||
ReskillEventsNativeTest.await(f, () -> rows("armorer.removed").size() == 1);
|
||||
var values = rows("armorer.removed").getFirst().getAsJsonObject("data");
|
||||
assertEquals(legacy.id().toString(), values.get("armorer_id").getAsString());
|
||||
assertEquals("unavailable", values.get("revision_capture_quality").getAsString()); assertFalse(values.has("armorer_revision"));
|
||||
assertEquals("unloaded", values.get("chunk_status").getAsString()); assertEquals("none_loaded", values.get("entity_outcome").getAsString());
|
||||
assertFalse(values.has("entity_id")); verify(actor.world, never()).getChunkAt(anyInt(), anyInt());
|
||||
assertTrue(directory.all().isEmpty()); assertTrue(rows("armorer.placed").isEmpty());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectedRealPlacementAndRemovalReplayOnlyOriginalAuditWithoutRecreatingRegistrationOrNpc() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
ReskillEventsNativeTest.await(f, () -> rows("armor.catalog_refreshed").size() == 1);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(actor.core.id, actor.player);
|
||||
doReturn(true).when(actor.player).hasPermission("fantasystick.admin"); when(actor.world.getEntities()).thenReturn(List.of());
|
||||
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
var directory = (FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"shop", "place", "Replay Forge"});
|
||||
ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(message -> message.startsWith("Placed Fantasy Armorer")));
|
||||
var saved = directory.all().getFirst(); assertNotNull(saved.revision());
|
||||
f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"shop", "remove", saved.id().toString()});
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().isEmpty() && actor.messages.stream().anyMatch(message -> message.startsWith("Removed Fantasy Armorer")));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var registrations = Files.readAllBytes(data.resolve("fantasy-armorers.yml"));
|
||||
var catalog = Files.readAllBytes(data.resolve("armor-catalog.yml")); var feedback = List.copyOf(actor.messages);
|
||||
var intents = new java.util.ArrayList<Properties>();
|
||||
try (var files = Files.list(data.resolve("armor-event-intents"))) {
|
||||
for (var path : files.filter(value -> value.getFileName().toString().endsWith(".pending")).toList()) {
|
||||
var properties = new Properties(); try (var input = Files.newInputStream(path)) { properties.load(input); } intents.add(properties);
|
||||
}
|
||||
}
|
||||
assertEquals(2, intents.size()); assertTrue(rows("armorer.placed").isEmpty()); assertTrue(rows("armorer.removed").isEmpty());
|
||||
f.players.clear(); actor.core.online = false; clearInvocations(actor.world); clearInvocations(actor.player);
|
||||
f.plugin.setEnabled(true);
|
||||
ReskillEventsNativeTest.await(f, () -> rows("armorer.placed").size() == 1 && rows("armorer.removed").size() == 1);
|
||||
assertTrue(((FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers")).all().isEmpty());
|
||||
assertArrayEquals(registrations, Files.readAllBytes(data.resolve("fantasy-armorers.yml"))); assertArrayEquals(catalog, Files.readAllBytes(data.resolve("armor-catalog.yml")));
|
||||
assertEquals(feedback, actor.messages); verifyNoInteractions(actor.world, actor.player);
|
||||
for (var intent : intents) {
|
||||
var replay = rows("armorer.placed").stream().filter(row -> row.get("id").getAsString().equals(intent.getProperty("id"))).findFirst()
|
||||
.orElseGet(() -> rows("armorer.removed").stream().filter(row -> row.get("id").getAsString().equals(intent.getProperty("id"))).findFirst().orElseThrow());
|
||||
assertEquals(intent.getProperty("time"), replay.get("time").getAsString());
|
||||
assertEquals(intent.getProperty("action"), replay.getAsJsonObject("data").get("action_id").getAsString());
|
||||
assertEquals(saved.id().toString(), replay.getAsJsonObject("data").get("armorer_id").getAsString());
|
||||
assertEquals(saved.revision().toString(), replay.getAsJsonObject("data").get("armorer_revision").getAsString());
|
||||
assertEquals(actor.core.id.toString(), replay.get("playerid").getAsString());
|
||||
}
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
private List<JsonObject> rows(String suffix) {
|
||||
try { return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith(suffix)).toList(); }
|
||||
catch (Exception failure) { throw new AssertionError("Cannot parse Armorer replay evidence", failure); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.adventure.FishingDirectory;
|
||||
import games.dmg.fantasystick.adventure.FishingState;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.bukkit.entity.Villager;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CampAttemptEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void actualChunkUnloadAndDisableResetTransientAttemptsWithoutRerollingRosterCancellingQuestOrRewarding()throws Exception{
|
||||
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);config.save(data.resolve("config.yml").toFile());
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
|
||||
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Accepted Same Camp;")));
|
||||
var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);fixture.pulse();
|
||||
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).count()==4);
|
||||
var first=fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).toList();
|
||||
var key=new org.bukkit.NamespacedKey(f.plugin,"adventure_attempt_id");
|
||||
String original=first.getFirst().getPersistentDataContainer().get(key,org.bukkit.persistence.PersistentDataType.STRING);
|
||||
var roster=List.copyOf(fixture.directory.snapshot().rosters().get(quest.site()));
|
||||
f.dispatcher.accept(new org.bukkit.event.world.ChunkUnloadEvent(actor.world.getChunkAt(0,-1),false));
|
||||
assertFalse(fixture.controller.hasActiveEncounter());assertTrue(first.stream().noneMatch(org.bukkit.entity.Entity::isValid));
|
||||
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(roster,fixture.directory.snapshot().rosters().get(quest.site()));
|
||||
fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).count()==8);
|
||||
var second=fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).skip(4).toList();
|
||||
String successor=second.getFirst().getPersistentDataContainer().get(key,org.bukkit.persistence.PersistentDataType.STRING);
|
||||
assertNotEquals(original,successor);assertEquals(roster,second.stream().map(org.bukkit.entity.Entity::getType).toList());
|
||||
assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
ReskillEventsNativeTest.await(f,()->{
|
||||
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.reset")).count()==1;}
|
||||
catch(Exception failure){throw new AssertionError(failure);}
|
||||
});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
|
||||
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
// A closed writer may reject the last shutdown export; immutable audit replay, never gameplay replay, finishes it.
|
||||
f.plugin.setEnabled(true);
|
||||
ReskillEventsNativeTest.await(f,()->{
|
||||
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.reset")).count()==2;}
|
||||
catch(Exception failure){throw new AssertionError(failure);}
|
||||
});
|
||||
assertFalse(((games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController")).hasActiveEncounter());
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
var rows=ReskillEventsNativeTest.records(data);var resets=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.reset")).toList();
|
||||
assertEquals(2,resets.size(),"Actual chunk-unload and orderly disable must each export the original transient native attempt reset");
|
||||
assertEquals(java.util.Set.of(original,successor),resets.stream().map(row->row.getAsJsonObject("data").get("attempt_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(java.util.Set.of("chunk_unload","plugin_disable"),resets.stream().map(row->row.getAsJsonObject("data").get("reason").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
for(var row:resets){var values=row.getAsJsonObject("data");assertEquals("succeeded",values.get("native_outcome").getAsString());
|
||||
assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());assertEquals(4,values.get("roster_total").getAsInt());assertEquals(0,values.get("remaining_entity_count").getAsInt());}
|
||||
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.camp.cleared")||row.get("type").getAsString().endsWith("adventure.trophy_earned")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredPersonalCampUsesAcknowledgedRosterRealNativeAttemptAndExistingSpoilsTurnInClaimsWithoutCopies()throws Exception{
|
||||
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);
|
||||
config.save(data.resolve("config.yml").toFile());
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
|
||||
var before=CombatExperienceNativeTest.record(f,actor);fixture.click(actor,fixture.open(actor),false);
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Accepted Same Camp;")));
|
||||
var quest=fixture.directory.active().getFirst();fixture.pulse();
|
||||
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).count()==4);
|
||||
var enemies=fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).toList();
|
||||
var roster=fixture.directory.snapshot().rosters().get(quest.site());assertEquals(4,roster.size());
|
||||
assertEquals(roster,enemies.stream().map(org.bukkit.entity.Entity::getType).toList());
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
|
||||
var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);
|
||||
var rejected=new EntityDamageByEntityEvent(outsider.player,enemies.getFirst(),EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);
|
||||
f.dispatcher.accept(rejected);assertTrue(rejected.isCancelled());
|
||||
for(var enemy:enemies){
|
||||
var hit=new EntityDamageByEntityEvent(actor.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);
|
||||
when(enemy.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
|
||||
var death=new org.bukkit.event.entity.EntityDeathEvent(enemy,source,new ArrayList<>(),7);f.dispatcher.accept(death);
|
||||
assertTrue(death.getDrops().isEmpty());assertEquals(0,death.getDroppedExp());
|
||||
f.dispatcher.accept(death); // Original duplicate native callback cannot clear or earn again.
|
||||
}
|
||||
var trophies=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
|
||||
ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty()&&trophies.trophies(quest.unit()).size()==1);
|
||||
assertEquals(before,CombatExperienceNativeTest.record(f,actor),"Native clear stores spoils, never immediate XP or coins");
|
||||
var trophy=trophies.trophies(quest.unit()).getFirst();assertEquals(FishingState.TrophyType.CAMP,trophy.type());
|
||||
assertEquals(java.util.Set.of(quest.unit()),trophy.rewards().keySet());assertEquals(quest.id(),trophy.quest());
|
||||
var giverMenu=fixture.open(actor);fixture.click(actor,giverMenu,slot(giverMenu,"Turn in quest items"),false);
|
||||
var turnMenu=actor.inventories.getLast();fixture.click(actor,turnMenu,slot(turnMenu,"Redeem Spoils of"),false);
|
||||
ReskillEventsNativeTest.await(f,()->trophies.trophies(quest.unit()).isEmpty()&&!trophies.unclaimed(quest.unit()).equals(FishingState.Reward.empty()));
|
||||
assertEquals(before,CombatExperienceNativeTest.record(f,actor),"Exact item consumption only unlocks its frozen share");
|
||||
giverMenu=fixture.open(actor);fixture.click(actor,giverMenu,slot(giverMenu,"Turn in quest items"),false);
|
||||
turnMenu=actor.inventories.getLast();fixture.click(actor,turnMenu,slot(turnMenu,"Claim earned quest rewards"),false);
|
||||
ReskillEventsNativeTest.await(f,()->trophies.unclaimed(quest.unit()).equals(FishingState.Reward.empty()));
|
||||
var paid=CombatExperienceNativeTest.record(f,actor);assertEquals(before.experience().add(quest.rewardXp()),paid.experience());
|
||||
assertEquals(before.coins().add(quest.rewardCoins()),paid.coins());
|
||||
ReskillEventsNativeTest.await(f,()->{
|
||||
try{var rows=ReskillEventsNativeTest.records(data);return java.util.stream.Stream.of("adventure.camp.started","adventure.camp.cleared",
|
||||
"adventure.trophy_earned","adventure.trophy_redeemed","adventure.reward_claimed").allMatch(type->rows.stream().filter(row->row.get("type").getAsString().endsWith(type)).count()==1);}
|
||||
catch(Exception failure){throw new AssertionError(failure);}
|
||||
});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
|
||||
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var rows=ReskillEventsNativeTest.records(data);var started=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.started")).toList();
|
||||
var cleared=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.cleared")).toList();
|
||||
assertEquals(1,started.size(),"Actual saved roster and registered native spawn must export their sole transient camp attempt");
|
||||
assertEquals(1,cleared.size(),"Actual final native death must export one clear, not duplicate callbacks or reward copies");
|
||||
for(var row:List.of(started.getFirst(),cleared.getFirst())){var values=row.getAsJsonObject("data");
|
||||
assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());assertEquals(quest.site().toString(),values.get("site_id").getAsString());
|
||||
assertEquals(trophy.source().toString(),values.get("attempt_id").getAsString());assertEquals("native_transient_not_persisted",values.get("attempt_capture_quality").getAsString());
|
||||
assertEquals(4,values.get("roster_total").getAsInt());assertEquals(4,values.getAsJsonArray("roster_entries").size());
|
||||
assertEquals("succeeded",values.get("native_outcome").getAsString());
|
||||
}
|
||||
var evidence=cleared.getFirst().getAsJsonObject("data");assertEquals(quest.id().toString(),evidence.getAsJsonArray("represented_quest_ids").get(0).getAsString());
|
||||
assertEquals(enemies.getLast().getUniqueId().toString(),evidence.get("final_enemy_id").getAsString());
|
||||
assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.trophy_earned")).count());
|
||||
assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.trophy_redeemed")).count());
|
||||
assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.reward_claimed")).count());
|
||||
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.quest.expired")),"Trophy completion is never an expiry");
|
||||
}
|
||||
}
|
||||
static int slot(org.bukkit.inventory.Inventory menu,String prefix){
|
||||
var plain=net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();
|
||||
return java.util.stream.IntStream.range(0,menu.getSize()).filter(index->{var item=menu.getItem(index);
|
||||
return item!=null&&item.getItemMeta().displayName()!=null&&plain.serialize(item.getItemMeta().displayName()).startsWith(prefix);}).findFirst().orElseThrow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestController;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Villager;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
|
||||
/** Typed supported camp/giver native ports; real controllers, storage, commands and writer remain installed. */
|
||||
final class CampAuditNativeFixture implements AutoCloseable {
|
||||
final FantasyStickLifecycleNativeTest.Fixture f;
|
||||
final FantasyStickLifecycleNativeTest.Actor actor;
|
||||
final AdventureQuestDirectory directory;
|
||||
final AdventureQuestController controller;
|
||||
final ArrayList<LivingEntity> spawned=new ArrayList<>();
|
||||
Consumer<LivingEntity> nativeSpawnSetup=ignored->{};
|
||||
Consumer<LivingEntity> nativeSpawnOutcome=ignored->{};
|
||||
CampAuditNativeFixture(Path data)throws Exception{
|
||||
f=new FantasyStickLifecycleNativeTest.Fixture(data);f.plugin.setEnabled(true);
|
||||
actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);
|
||||
when(actor.world.getUID()).thenReturn(UUID.randomUUID());when(actor.world.getName()).thenReturn("Camp Realm");
|
||||
when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
|
||||
var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(0);when(chunk.getZ()).thenReturn(-1);
|
||||
when(chunk.getEntities()).thenAnswer(call->spawned.stream().filter(Entity::isValid).toArray(Entity[]::new));
|
||||
when(actor.world.getEntities()).thenAnswer(call->new ArrayList<Entity>(spawned.stream().filter(Entity::isValid).toList()));
|
||||
when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(chunk);
|
||||
doAnswer(call->{
|
||||
Class<? extends LivingEntity> type=call.getArgument(1);LivingEntity entity=mock(type);
|
||||
var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());
|
||||
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getPersistentDataContainer()).thenReturn(pdc);
|
||||
when(entity.getWorld()).thenReturn(actor.world);Location at=call.getArgument(0);when(entity.getLocation()).thenReturn(at.clone());
|
||||
when(entity.isValid()).thenReturn(true);when(entity.getVelocity()).thenReturn(new org.bukkit.util.Vector());
|
||||
when(entity.getType()).thenReturn(entity instanceof Villager?org.bukkit.entity.EntityType.VILLAGER:
|
||||
entity instanceof org.bukkit.entity.Husk?org.bukkit.entity.EntityType.HUSK:
|
||||
entity instanceof org.bukkit.entity.Pillager?org.bukkit.entity.EntityType.PILLAGER:
|
||||
entity instanceof org.bukkit.entity.Vindicator?org.bukkit.entity.EntityType.VINDICATOR:org.bukkit.entity.EntityType.WITCH);
|
||||
doAnswer(removal->{when(entity.isValid()).thenReturn(false);return null;}).when(entity).remove();
|
||||
Consumer<? super LivingEntity> configure=call.getArgument(2);nativeSpawnSetup.accept(entity);configure.accept(entity);nativeSpawnOutcome.accept(entity);spawned.add(entity);return entity;
|
||||
}).when(actor.world).spawn(any(Location.class),org.mockito.ArgumentMatchers.<Class<LivingEntity>>any(),org.mockito.ArgumentMatchers.<Consumer<? super LivingEntity>>any());
|
||||
directory=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
|
||||
controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");
|
||||
}
|
||||
void camp(String mode)throws Exception{
|
||||
long count=actor.messages.stream().filter(v->v.startsWith("Placed camp")).count();
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","camp","--mode",mode,"Same Camp"});
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().filter(v->v.startsWith("Placed camp")).count()==count+1);
|
||||
}
|
||||
void giver()throws Exception{
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"quest-giver","place","Guide"});
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Placed Quest Giver")));
|
||||
}
|
||||
void resetCatalog()throws Exception{
|
||||
long count=actor.messages.stream().filter(v->v.startsWith("Reset daily adventures:")).count();
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure","reroll","confirm"});
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().filter(v->v.startsWith("Reset daily adventures:")).count()==count+1);
|
||||
}
|
||||
Inventory open(FantasyStickLifecycleNativeTest.Actor holder)throws Exception{
|
||||
int count=holder.inventories.size();var giver=spawned.stream().filter(Villager.class::isInstance).filter(Entity::isValid).findFirst().orElseThrow();
|
||||
var event=new org.bukkit.event.player.PlayerInteractEntityEvent(holder.player,giver,org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(event);assertTrue(event.isCancelled());ReskillEventsNativeTest.await(f,()->holder.inventories.size()>count);
|
||||
return holder.inventories.getLast();
|
||||
}
|
||||
void click(FantasyStickLifecycleNativeTest.Actor holder,Inventory menu,boolean right){click(holder,menu,0,right);}
|
||||
void click(FantasyStickLifecycleNativeTest.Actor holder,Inventory menu,int slot,boolean right){
|
||||
var view=mock(org.bukkit.inventory.InventoryView.class);
|
||||
when(view.getTopInventory()).thenReturn(menu);when(view.getBottomInventory()).thenReturn(holder.core.inventory);
|
||||
when(view.getPlayer()).thenReturn(holder.player);when(view.getType()).thenReturn(menu.getType());
|
||||
when(view.getItem(slot)).thenReturn(menu.getItem(slot));when(view.getInventory(slot)).thenReturn(menu);
|
||||
var event=new InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,slot,
|
||||
right?org.bukkit.event.inventory.ClickType.RIGHT:org.bukkit.event.inventory.ClickType.LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
|
||||
f.dispatcher.accept(event);assertTrue(event.isCancelled());
|
||||
}
|
||||
void pulse(){for(var task:java.util.List.copyOf(f.pulses))task.run();}
|
||||
@Override public void close()throws java.io.IOException{f.close();}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
|
||||
|
||||
import games.dmg.fantasystick.events.CampEventBridge;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CampAuditSafetyReplayNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void deniedInvalidSameValueLockedAndClosedCampSettingsCannotPublishPositiveCopies()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var denied=new EquipmentAdminCommandNativeTest.Sender();denied.allowed=false;
|
||||
var settings=(AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");
|
||||
var key=AdventureQuestSettings.DAILY_COUNT;
|
||||
f.adminCommand.execute(denied.port,"fstickadmin",new String[]{"config",key});f.adminCommand.execute(denied.port,"fstickadmin",new String[]{"config",key,"4"});
|
||||
for(String invalid:List.of("-1","21","NaN","1.5"))f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,invalid});
|
||||
f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"4","extra"});assertEquals("3",settings.value(key));
|
||||
f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"3"});
|
||||
ReskillEventsNativeTest.await(f,()->sender.messages.stream().anyMatch(value->value.startsWith("Saved "+key+"=3")));
|
||||
var bytes=Files.readAllBytes(data.resolve("config.yml"));
|
||||
try(var channel=FileChannel.open(data.resolve(".config.lock"),StandardOpenOption.CREATE,StandardOpenOption.WRITE);var lock=channel.lock()){
|
||||
assertTrue(lock.isValid());f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"4"});
|
||||
ReskillEventsNativeTest.await(f,()->sender.messages.contains("Adventure quest configuration save failed; effective values are unchanged."));
|
||||
assertEquals("3",settings.value(key));assertArrayEquals(bytes,Files.readAllBytes(data.resolve("config.yml")));
|
||||
}
|
||||
sender.messages.clear();settings.close();f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"5"});
|
||||
ReskillEventsNativeTest.await(f,()->sender.messages.contains("Adventure quest configuration save failed; effective values are unchanged."));
|
||||
assertEquals("3",settings.value(key));assertArrayEquals(bytes,Files.readAllBytes(data.resolve("config.yml")));
|
||||
f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key});
|
||||
ReskillEventsNativeTest.await(f,()->{try{return configRows().size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");assertEquals(1,configRows().size());
|
||||
assertTrue(configRows().getFirst().get("type").getAsString().endsWith("config.read"));
|
||||
}
|
||||
}
|
||||
@Test void rejectedRealCampReadAndQueuedPublicationsReplayEveryOriginalPropertyOfflineWithoutGameplayOrConfigAccess()throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
|
||||
((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();
|
||||
String key=AdventureQuestSettings.DAILY_COUNT;
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key});
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,"4"});
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,"5"});actor.core.location.setX(77);
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved "+key+"=5")));
|
||||
assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");drain(f,"configurationDrain");
|
||||
var original=intents(data.resolve("camp-event-intents"));assertEquals(3,original.size());assertTrue(configRows().isEmpty());
|
||||
var bytes=Files.readAllBytes(data.resolve("config.yml"));var inventory=actor.core.snapshot();var messages=List.copyOf(actor.messages);
|
||||
f.players.clear();clearInvocations(actor.player);f.plugin.setEnabled(true);
|
||||
ReskillEventsNativeTest.await(f,()->{try{return configRows().size()==3;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
assertArrayEquals(bytes,Files.readAllBytes(data.resolve("config.yml")));assertArrayEquals(inventory,actor.core.snapshot());assertEquals(messages,actor.messages);
|
||||
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
|
||||
assertEquals(before,storage.find(before.id()).get().orElseThrow());verifyNoInteractions(actor.player);
|
||||
var rows=configRows();var changed=rows.stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).toList();assertEquals(2,changed.size());
|
||||
assertEquals(java.util.Map.of("3","4","4","5"),changed.stream().collect(java.util.stream.Collectors.toMap(
|
||||
row->row.getAsJsonObject("data").get("before").getAsString(),row->row.getAsJsonObject("data").get("after").getAsString())),
|
||||
"Replay preserves exact acknowledged transitions, not UUID-file arrival order");
|
||||
for(var intent:original)assertOriginal(intent,rows.stream().filter(row->row.get("id").getAsString().equals(intent.getProperty("id"))).findFirst().orElseThrow());
|
||||
var bridge=(CampEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditEvents");bridge.replay();bridge.replay();
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");assertEquals(3,configRows().size());assertEquals(messages,actor.messages);
|
||||
}
|
||||
}
|
||||
private List<com.google.gson.JsonObject> configRows()throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->
|
||||
row.getAsJsonObject("data").has("key")&&CampEventBridge.ownsSetting(row.getAsJsonObject("data").get("key").getAsString())).toList();}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CampCatalogEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void actualRegisteredConfirmedResetPublishesOneCompleteSharedUtcCatalogWithCapturedOfferSettings() throws Exception {
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id,actor.player);when(actor.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(actor.world.getName()).thenReturn("Catalog Realm");
|
||||
var directory=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
|
||||
for(int i=0;i<2;i++){
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","camp","--mode",i==0?"individual-only":"party-required","Same Name"});
|
||||
final int count=i+1;ReskillEventsNativeTest.await(f,()->actor.messages.stream().filter(v->v.startsWith("Placed camp")).count()==count);
|
||||
}
|
||||
var sites=directory.snapshot().sites();
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure","reroll","confirm"});
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Reset daily adventures:")));
|
||||
var catalog=directory.snapshot().catalog();assertNotNull(catalog);assertNull(directory.snapshot().reset());
|
||||
assertEquals(Set.copyOf(sites.stream().map(v->v.id()).toList()),Set.copyOf(catalog.sites()));
|
||||
ReskillEventsNativeTest.await(f,()->{
|
||||
try{return ReskillEventsNativeTest.records(data).stream().anyMatch(row->row.get("type").getAsString().endsWith("adventure.catalog_refreshed")
|
||||
&&row.getAsJsonObject("data").get("catalog_id").getAsString().equals(catalog.revision().toString()));}
|
||||
catch(Exception failure){throw new AssertionError(failure);}
|
||||
});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
|
||||
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.catalog_refreshed")
|
||||
&&row.getAsJsonObject("data").get("catalog_id").getAsString().equals(catalog.revision().toString())).toList();
|
||||
assertEquals(1,rows.size(),"Actual complete durable UTC catalog publication must export one owner event, not a menu or pending-reset copy");
|
||||
var row=rows.getFirst();var values=row.getAsJsonObject("data");
|
||||
assertEquals(catalog.revision().toString(),values.get("catalog_revision").getAsString());
|
||||
assertEquals(catalog.day().toString(),values.get("utc_day").getAsString());
|
||||
assertEquals(catalog.createdAt().toString(),values.get("created_at").getAsString());
|
||||
assertEquals(catalog.expiresAt().toString(),values.get("next_midnight").getAsString());
|
||||
assertEquals("admin_full_reroll",values.get("reason").getAsString());assertEquals("acknowledged",values.get("outcome").getAsString());
|
||||
assertEquals(3,values.getAsJsonObject("offer_settings").get("adventure-daily-site-count").getAsInt());
|
||||
assertEquals(2,values.getAsJsonArray("selected_sites").size());
|
||||
for(var site:sites){var selected=java.util.stream.StreamSupport.stream(values.getAsJsonArray("selected_sites").spliterator(),false)
|
||||
.map(v->v.getAsJsonObject()).filter(v->v.get("site_id").getAsString().equals(site.id().toString())).findFirst().orElseThrow();
|
||||
assertEquals("camp",selected.get("site_kind").getAsString());assertEquals(site.participationMode().value(),selected.get("mode").getAsString());
|
||||
assertEquals(site.registrationRevision().toString(),selected.get("site_revision").getAsString());
|
||||
}
|
||||
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CampConfigurationEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
private record Setting(String key,String before,String after,String scope){}
|
||||
private static final List<Setting> SETTINGS=List.of(
|
||||
new Setting(AdventureQuestSettings.DAILY_COUNT,"3","4","next_catalog"),
|
||||
new Setting(AdventureQuestSettings.RADIUS,"16","24","new_sites_and_future_legacy_geometry_attempts"),
|
||||
new Setting(AdventureQuestSettings.RESET_SECONDS,"30","45","future_attempts"),
|
||||
new Setting(AdventureQuestSettings.CAMP_ENEMIES,"8","12","future_uncaptured_catalog_day_rosters"),
|
||||
new Setting(AdventureQuestSettings.CAMP_REWARD_XP,"100","125","future_acceptances"),
|
||||
new Setting(AdventureQuestSettings.CAMP_REWARD_COINS,"20","35","future_acceptances"));
|
||||
@Test void allSixRegisteredCampCatalogReadsAndSavedPublicationsExportExactHistoryWithoutRewritingCurrentQuestCatalogOrGeometry()throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
|
||||
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Accepted Same Camp;")));
|
||||
var quest=fixture.directory.active().getFirst();var catalog=fixture.directory.snapshot().catalog();var site=fixture.directory.findSite(quest.site()).orElseThrow();
|
||||
var character=CombatExperienceNativeTest.record(f,actor);var settings=(AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");
|
||||
for(var setting:SETTINGS){
|
||||
actor.core.location.setX(12);f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key()});
|
||||
assertTrue(actor.messages.stream().anyMatch(value->value.startsWith(setting.key()+"="+setting.before()+" ")));
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key(),setting.after()});actor.core.location.setX(77);
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved "+setting.key()+"="+setting.after()+" for later")));
|
||||
assertEquals(setting.after(),settings.value(setting.key()));actor.core.location.setX(12);
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key()});
|
||||
}
|
||||
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(catalog,fixture.directory.snapshot().catalog());
|
||||
assertEquals(site,fixture.directory.findSite(quest.site()).orElseThrow());assertEquals(character,CombatExperienceNativeTest.record(f,actor));
|
||||
ReskillEventsNativeTest.await(f,()->{
|
||||
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.getAsJsonObject("data").has("key")
|
||||
&&SETTINGS.stream().anyMatch(setting->setting.key().equals(row.getAsJsonObject("data").get("key").getAsString()))).count()==18;}
|
||||
catch(Exception failure){throw new AssertionError(failure);}
|
||||
});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
|
||||
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.getAsJsonObject("data").has("key")
|
||||
&&SETTINGS.stream().anyMatch(setting->setting.key().equals(row.getAsJsonObject("data").get("key").getAsString()))).toList();
|
||||
assertEquals(18,rows.size(),"Every actual registered camp/catalog setting read and acknowledged publication must export its owning JSONL history");
|
||||
for(var setting:SETTINGS){var owned=rows.stream().filter(row->row.getAsJsonObject("data").get("key").getAsString().equals(setting.key())).toList();
|
||||
var reads=owned.stream().filter(row->row.get("type").getAsString().endsWith("config.read")).toList();
|
||||
assertEquals(List.of(setting.before(),setting.after()),reads.stream().map(row->row.getAsJsonObject("data").get("effective").getAsString()).toList());
|
||||
var changes=owned.stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).toList();assertEquals(1,changes.size());
|
||||
var values=changes.getFirst().getAsJsonObject("data");assertEquals(setting.before(),values.get("before").getAsString());assertEquals(setting.after(),values.get("after").getAsString());
|
||||
for(var row:owned){var dataValues=row.getAsJsonObject("data");assertEquals(setting.scope(),dataValues.get("application_scope").getAsString());
|
||||
assertEquals(setting.before(),dataValues.get("default").getAsString());assertEquals(12,dataValues.getAsJsonObject("location").get("x").getAsDouble());
|
||||
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(quest.unit().toString(),row.get("characterid").getAsString());}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CampMetadataFaultNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void nativeCancelledMaterializationCannotBeClaimedAsSuccessfulCampSpawnOrEarnCompletion()throws Exception{
|
||||
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);config.save(data.resolve("config.yml").toFile());
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
|
||||
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Accepted Same Camp;")));
|
||||
var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);
|
||||
fixture.nativeSpawnOutcome=entity->{if(!(entity instanceof org.bukkit.entity.Villager))when(entity.isValid()).thenReturn(false);};
|
||||
fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof org.bukkit.entity.Villager)).count()==4);
|
||||
assertEquals(4,fixture.directory.snapshot().rosters().get(quest.site()).size());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());
|
||||
assertEquals(before,CombatExperienceNativeTest.record(f,actor));ReskillEventsNativeTest.await(f,()->rows("adventure.camp.started").size()==1);
|
||||
var values=rows("adventure.camp.started").getFirst().getAsJsonObject("data");
|
||||
assertEquals("not_materialized",values.get("native_outcome").getAsString(),"Returned native spawn/configure calls are not accepted materialization when native addition was cancelled");
|
||||
assertEquals("native_not_materialized",values.get("outcome").getAsString());assertEquals(4,values.get("roster_total").getAsInt());
|
||||
assertTrue(rows("adventure.camp.cleared").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unavailableOptionalActorNameRetainsKnownPlayerCharacterSessionLocationAndAcknowledgedCampRevision()throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
|
||||
doThrow(new IllegalStateException("Unavailable optional actor name")).when(actor.player).getName();fixture.camp("individual-only");
|
||||
var saved=fixture.directory.snapshot().sites().getFirst();assertNotNull(saved.registrationRevision());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
doReturn("Later restored name").when(actor.player).getName();
|
||||
ReskillEventsNativeTest.await(f,()->rows("adventure.site.registered").size()==1);
|
||||
var values=rows("adventure.site.registered").getFirst().getAsJsonObject("data");
|
||||
assertEquals(actor.core.id.toString(),values.get("request_player_id").getAsString());assertEquals(before.id().toString(),values.get("request_character_id").getAsString());
|
||||
assertTrue(values.has("request_session_id"));assertFalse(values.has("request_player_name"));assertEquals("unavailable",values.get("actor_capture_quality").getAsString());
|
||||
assertEquals(12,values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(saved.registrationRevision().toString(),values.get("site_revision").getAsString());
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");assertEquals(1,rows("adventure.site.registered").size());
|
||||
}
|
||||
}
|
||||
@Test void unloadedSavedGiverReportsUnavailableNativeMaterializationWithoutForceLoadingOrInventingEntityIdentity()throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(false);clearInvocations(actor.world);
|
||||
fixture.giver();assertTrue(fixture.spawned.isEmpty());var saved=fixture.directory.snapshot().givers().getFirst();assertNotNull(saved.registrationRevision());
|
||||
ReskillEventsNativeTest.await(f,()->rows("adventure.quest_giver.placed").size()==1);
|
||||
var values=rows("adventure.quest_giver.placed").getFirst().getAsJsonObject("data");
|
||||
assertEquals(saved.id().toString(),values.get("giver_id").getAsString());assertEquals(saved.registrationRevision().toString(),values.get("giver_revision").getAsString());
|
||||
assertEquals("not_loaded",values.get("entity_outcome").getAsString());assertTrue(values.getAsJsonArray("entity_ids_after").isEmpty());
|
||||
verify(actor.world,never()).getChunkAt(anyInt(),anyInt());
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");
|
||||
}
|
||||
}
|
||||
@Test void nonDirectoryOwnerIntentPathCannotPreventRealSavedCampCatalogNativeClearOrExistingSpoils()throws Exception{
|
||||
java.nio.file.Files.writeString(data.resolve("camp-event-intents"),"occupied");
|
||||
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);config.save(data.resolve("config.yml").toFile());
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
|
||||
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Accepted Same Camp;")));
|
||||
var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);fixture.pulse();
|
||||
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof org.bukkit.entity.Villager)).count()==4);
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
|
||||
for(var enemy:fixture.spawned.stream().filter(entity->!(entity instanceof org.bukkit.entity.Villager)).toList()){
|
||||
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);
|
||||
when(enemy.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,source,new java.util.ArrayList<>(),0));
|
||||
}
|
||||
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
|
||||
ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty()&&trophies.trophies(quest.unit()).size()==1);
|
||||
assertEquals(before,CombatExperienceNativeTest.record(f,actor));assertEquals("occupied",java.nio.file.Files.readString(data.resolve("camp-event-intents")));
|
||||
ReskillEventsNativeTest.await(f,()->rows("adventure.trophy_earned").size()==1);
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");
|
||||
assertTrue(rows("adventure.camp.started").isEmpty());assertTrue(rows("adventure.camp.cleared").isEmpty());
|
||||
assertEquals(1,rows("adventure.trophy_earned").size(),"Existing trophy owner remains independent of failed camp audit storage");
|
||||
}
|
||||
}
|
||||
private java.util.List<com.google.gson.JsonObject> rows(String type){try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();}
|
||||
catch(Exception failure){throw new AssertionError(failure);}}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
|
||||
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.bukkit.entity.Villager;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CampMultipartReplayNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void actualSavedSixtyFourEnemyNativeAttemptExportsEveryOriginalRosterEntityAndResetPartThenReplaysOfflineWithoutSpawning()throws Exception{
|
||||
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",64);config.save(data.resolve("config.yml").toFile());
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
|
||||
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Accepted Same Camp;")));
|
||||
var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);fixture.pulse();
|
||||
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).count()==64);
|
||||
var enemies=fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).toList();var roster=fixture.directory.snapshot().rosters().get(quest.site());
|
||||
assertEquals(64,roster.size());assertEquals(roster,enemies.stream().map(org.bukkit.entity.Entity::getType).toList());
|
||||
ReskillEventsNativeTest.await(f,()->rows("adventure.camp.started").size()==4);
|
||||
var starts=rows("adventure.camp.started");assertEquals(4,starts.stream().map(row->row.get("id").getAsString()).distinct().count());
|
||||
var entries=new java.util.TreeMap<Integer,String>();var ids=new java.util.HashSet<String>();
|
||||
for(var row:starts){var values=row.getAsJsonObject("data");assertEquals(4,values.get("part_count").getAsInt());assertEquals(64,values.get("roster_total").getAsInt());
|
||||
for(var entry:values.getAsJsonArray("roster_entries")){var fact=entry.getAsJsonObject();assertNull(entries.put(fact.get("roster_index").getAsInt(),fact.get("entity_type").getAsString()));}
|
||||
for(var entity:values.getAsJsonArray("native_entities"))assertTrue(ids.add(entity.getAsJsonObject().get("entity_id").getAsString()));}
|
||||
assertEquals(roster.stream().map(Enum::name).toList(),List.copyOf(entries.values()));
|
||||
assertEquals(enemies.stream().map(entity->entity.getUniqueId().toString()).collect(java.util.stream.Collectors.toSet()),ids);
|
||||
((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");
|
||||
var originals=intents(data.resolve("camp-event-intents"));assertEquals(4,originals.size());
|
||||
assertTrue(originals.stream().allMatch(intent->intent.getProperty("type").equals("adventure.camp.reset")));
|
||||
var bytes=java.nio.file.Files.readAllBytes(data.resolve("adventure-quests.yml"));var inventory=actor.core.snapshot();var messages=List.copyOf(actor.messages);
|
||||
f.players.clear();clearInvocations(actor.player);f.plugin.setEnabled(true);ReskillEventsNativeTest.await(f,()->rows("adventure.camp.reset").size()==4);
|
||||
var resets=rows("adventure.camp.reset");for(var original:originals){
|
||||
var decoded=new java.util.Properties();decoded.putAll(original);
|
||||
// Existing legacy slot-list encoding also represents empty lists; normalize only that exact empty value for this reader.
|
||||
for(String key:decoded.stringPropertyNames())if(key.startsWith("data.")&&decoded.getProperty(key).equals("l"))decoded.setProperty(key,"j[]");
|
||||
assertOriginal(decoded,resets.stream().filter(row->row.get("id").getAsString().equals(original.getProperty("id"))).findFirst().orElseThrow());
|
||||
}
|
||||
assertArrayEquals(bytes,java.nio.file.Files.readAllBytes(data.resolve("adventure-quests.yml")));assertArrayEquals(inventory,actor.core.snapshot());assertEquals(messages,actor.messages);verifyNoInteractions(actor.player);
|
||||
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(before,storage.find(before.id()).get().orElseThrow());
|
||||
assertFalse(((games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController")).hasActiveEncounter());
|
||||
assertEquals(64,fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).count());
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");assertEquals(4,rows("adventure.camp.reset").size());
|
||||
assertTrue(rows("adventure.camp.cleared").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
|
||||
}
|
||||
}
|
||||
private List<com.google.gson.JsonObject> rows(String type){try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();}
|
||||
catch(Exception failure){throw new AssertionError(failure);}}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static games.dmg.fantasystick.CampAttemptEventsNativeTest.slot;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class CampPartyEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void mutedRegisteredPartyClearExcludesCancelledSoloAndAddedMemberThenRedeemsOfflineLeadersFrozenShare()throws Exception{
|
||||
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);config.save(data.resolve("config.yml").toFile());
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var leader=fixture.actor;f.activate(leader);
|
||||
var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(leader.world);f.players.put(member.core.id,member.player);f.activate(member);
|
||||
var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(leader.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(member.core.id,parties.invite(leader.core.id,party.id(),member.core.id).orElseThrow().id()));
|
||||
fixture.camp("party-available");fixture.giver();fixture.resetCatalog();fixture.click(leader,fixture.open(leader),true);
|
||||
ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();
|
||||
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(leader.world);f.players.put(solo.core.id,solo.player);f.activate(solo);
|
||||
fixture.click(solo,fixture.open(solo),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==2);
|
||||
var soloQuest=fixture.directory.active().stream().filter(value->value.unitType()==AdventureQuestState.UnitType.SOLO).findFirst().orElseThrow();
|
||||
var added=new FantasyStickLifecycleNativeTest.Actor(data);added.core.location.setWorld(leader.world);f.players.put(added.core.id,added.player);f.activate(added);
|
||||
assertTrue(parties.accept(added.core.id,parties.invite(leader.core.id,party.id(),added.core.id).orElseThrow().id()));
|
||||
assertFalse(quest.snapshot().contains(added.core.record.id()));
|
||||
for(var holder:java.util.List.of(leader,member)){
|
||||
assertTrue(f.playerCommand.execute(holder.player,"fstick",new String[]{"feedback","off"}));
|
||||
ReskillEventsNativeTest.await(f,()->holder.messages.contains("Fantasy feedback is now off."));
|
||||
}
|
||||
var preferences=(games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"feedbackPreferences");
|
||||
assertFalse(preferences.enabled(leader.core.id));assertFalse(preferences.enabled(member.core.id));
|
||||
var leaderBefore=CombatExperienceNativeTest.record(f,leader);var memberBefore=CombatExperienceNativeTest.record(f,member);
|
||||
var addedBefore=CombatExperienceNativeTest.record(f,added);var soloBefore=CombatExperienceNativeTest.record(f,solo);
|
||||
fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof org.bukkit.entity.Villager)).count()==4);
|
||||
var enemies=fixture.spawned.stream().filter(entity->!(entity instanceof org.bukkit.entity.Villager)).toList();
|
||||
var cancelledSource=mock(org.bukkit.damage.DamageSource.class);when(cancelledSource.getCausingEntity()).thenReturn(solo.player);when(cancelledSource.getDirectEntity()).thenReturn(solo.player);
|
||||
var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(solo.player,enemies.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,cancelledSource,1);
|
||||
cancelled.setCancelled(true);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
|
||||
var rejectedSource=mock(org.bukkit.damage.DamageSource.class);when(rejectedSource.getCausingEntity()).thenReturn(added.player);when(rejectedSource.getDirectEntity()).thenReturn(added.player);
|
||||
var rejected=new org.bukkit.event.entity.EntityDamageByEntityEvent(added.player,enemies.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,rejectedSource,1);
|
||||
f.dispatcher.accept(rejected);assertTrue(rejected.isCancelled());
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(member.player);when(source.getDirectEntity()).thenReturn(member.player);
|
||||
doReturn(false).when(leader.player).isOnline();
|
||||
for(var enemy:enemies){
|
||||
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(member.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);
|
||||
when(enemy.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
|
||||
var death=new org.bukkit.event.entity.EntityDeathEvent(enemy,source,new java.util.ArrayList<>(),0);f.dispatcher.accept(death);f.dispatcher.accept(death);
|
||||
}
|
||||
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
|
||||
ReskillEventsNativeTest.await(f,()->fixture.directory.findQuest(quest.id()).isEmpty()&&trophies.trophies(leaderBefore.id()).size()==1);
|
||||
var trophy=trophies.trophies(leaderBefore.id()).getFirst();assertEquals(quest.id(),trophy.quest());assertEquals(party.id(),quest.unit());
|
||||
assertEquals(Set.of(memberBefore.id()),trophy.rewards().keySet());assertEquals(quest.rewardXp(),trophy.total().xp());assertEquals(quest.rewardCoins(),trophy.total().coins());
|
||||
assertEquals(quest.snapshot(),Set.of(leaderBefore.id(),memberBefore.id()));assertEquals(soloQuest,fixture.directory.findQuest(soloQuest.id()).orElseThrow());
|
||||
assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));
|
||||
assertEquals(addedBefore,CombatExperienceNativeTest.record(f,added));assertEquals(soloBefore,CombatExperienceNativeTest.record(f,solo));
|
||||
doReturn(true).when(leader.player).isOnline();var menu=fixture.open(leader);fixture.click(leader,menu,slot(menu,"Turn in quest items"),false);
|
||||
menu=leader.inventories.getLast();fixture.click(leader,menu,slot(menu,"Redeem Spoils of"),false);
|
||||
ReskillEventsNativeTest.await(f,()->trophies.trophies(leaderBefore.id()).isEmpty()&&!trophies.unclaimed(memberBefore.id()).equals(games.dmg.fantasystick.adventure.FishingState.Reward.empty()));
|
||||
assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));
|
||||
menu=fixture.open(member);fixture.click(member,menu,slot(menu,"Turn in quest items"),false);menu=member.inventories.getLast();fixture.click(member,menu,slot(menu,"Claim earned quest rewards"),false);
|
||||
ReskillEventsNativeTest.await(f,()->trophies.unclaimed(memberBefore.id()).equals(games.dmg.fantasystick.adventure.FishingState.Reward.empty()));
|
||||
var paid=CombatExperienceNativeTest.record(f,member);assertEquals(memberBefore.experience().add(quest.rewardXp()),paid.experience());assertEquals(memberBefore.coins().add(quest.rewardCoins()),paid.coins());
|
||||
assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(addedBefore,CombatExperienceNativeTest.record(f,added));assertEquals(soloBefore,CombatExperienceNativeTest.record(f,solo));
|
||||
ReskillEventsNativeTest.await(f,()->{
|
||||
try{var rows=ReskillEventsNativeTest.records(data);return java.util.stream.Stream.of("adventure.camp.started","adventure.camp.cleared","adventure.trophy_earned","adventure.trophy_redeemed","adventure.reward_claimed")
|
||||
.allMatch(type->rows.stream().filter(row->row.get("type").getAsString().endsWith(type)).count()==1);}
|
||||
catch(Exception failure){throw new AssertionError(failure);}
|
||||
});
|
||||
var rows=ReskillEventsNativeTest.records(data);var cleared=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.cleared")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
var represented=cleared.getAsJsonArray("represented_units");assertEquals(1,represented.size());var unit=represented.get(0).getAsJsonObject();
|
||||
assertEquals(quest.id().toString(),unit.get("quest_id").getAsString());assertEquals(party.id().toString(),unit.get("unit_id").getAsString());assertEquals("party",unit.get("unit_kind").getAsString());
|
||||
assertEquals(trophy.source().toString(),cleared.get("attempt_id").getAsString());assertFalse(preferences.enabled(member.core.id));
|
||||
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.quest.expired")));
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();games.dmg.fantasystick.WitchAuditTestSupport.drain(f,"campAuditDrain");
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings={"individual-only","party-available","party-required"})
|
||||
void registeredExplicitModesKeepCharacterOrPersistentPartyAuthorityFrozenAcrossTransferAdditionAndDisband(String mode)throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var leader=fixture.actor;var member=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
member.core.location.setWorld(leader.world);f.players.put(member.core.id,member.player);f.activate(leader);f.activate(member);
|
||||
var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(leader.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(member.core.id,parties.invite(leader.core.id,party.id(),member.core.id).orElseThrow().id()));
|
||||
// Durable US-052 party is a completed prerequisite; camp administration and all acceptance choices use actual registered interfaces.
|
||||
fixture.camp(mode);fixture.giver();fixture.resetCatalog();
|
||||
boolean personal=mode.equals("individual-only");
|
||||
var leaderBefore=CombatExperienceNativeTest.record(f,leader);var memberBefore=CombatExperienceNativeTest.record(f,member);
|
||||
if(personal){fixture.click(leader,fixture.open(leader),true);
|
||||
assertTrue(leader.messages.contains("Quest acceptance refused: this site is individual-only."));assertTrue(fixture.directory.active().isEmpty());
|
||||
fixture.click(member,fixture.open(member),false);
|
||||
}else{
|
||||
fixture.click(member,fixture.open(member),true);assertTrue(member.messages.contains("Only the active party leader may accept a party quest."));
|
||||
assertTrue(fixture.directory.active().isEmpty());
|
||||
if(mode.equals("party-required")){fixture.click(leader,fixture.open(leader),false);
|
||||
assertTrue(leader.messages.contains("Quest acceptance refused: this site is party-required."));assertTrue(fixture.directory.active().isEmpty());}
|
||||
fixture.click(leader,fixture.open(leader),true);
|
||||
}
|
||||
var accepting=personal?member:leader;
|
||||
ReskillEventsNativeTest.await(f,()->accepting.messages.stream().anyMatch(value->value.startsWith("Accepted Same Camp;")));
|
||||
var quest=fixture.directory.active().getFirst();assertEquals(mode,quest.participationMode().value());
|
||||
assertEquals(personal?AdventureQuestState.UnitType.SOLO:AdventureQuestState.UnitType.PARTY,quest.unitType());
|
||||
assertEquals(personal?member.core.record.id():party.id(),quest.unit());
|
||||
assertEquals(personal?Set.of(member.core.record.id()):Set.of(leader.core.record.id(),member.core.record.id()),quest.snapshot());
|
||||
var durable=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");
|
||||
assertEquals(games.dmg.fantasystick.party.CharacterPartyDirectory.Status.APPLIED,
|
||||
durable.transfer(leader.core.record.id(),party.id(),member.core.record.id()).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
|
||||
var added=new FantasyStickLifecycleNativeTest.Actor(data);added.core.location.setWorld(leader.world);f.players.put(added.core.id,added.player);f.activate(added);
|
||||
assertTrue(parties.accept(added.core.id,parties.invite(member.core.id,party.id(),added.core.id).orElseThrow().id()));
|
||||
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertFalse(quest.snapshot().contains(added.core.record.id()));
|
||||
assertTrue(parties.disband(member.core.id,party.id()));fixture.pulse();
|
||||
if(personal)assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());
|
||||
else ReskillEventsNativeTest.await(f,()->fixture.directory.findQuest(quest.id()).isEmpty());
|
||||
assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));
|
||||
ReskillEventsNativeTest.await(f,()->{
|
||||
try{var rows=ReskillEventsNativeTest.records(data);return rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.accepted")).count()==1
|
||||
&&(personal||rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.expired")).count()==1);}
|
||||
catch(Exception failure){throw new AssertionError(failure);}
|
||||
});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
|
||||
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var rows=ReskillEventsNativeTest.records(data);var accepted=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.accepted")).toList();
|
||||
assertEquals(1,accepted.size());var values=accepted.getFirst().getAsJsonObject("data");
|
||||
assertEquals(quest.id().toString(),values.get("quest_id").getAsString());assertEquals(mode,values.get("captured_mode").getAsString());
|
||||
assertEquals(quest.unit().toString(),values.get("unit_id").getAsString());assertEquals(personal?"solo":"party",values.get("unit_kind").getAsString());
|
||||
assertEquals(quest.snapshot().stream().map(Object::toString).collect(java.util.stream.Collectors.toSet()),
|
||||
java.util.stream.StreamSupport.stream(values.getAsJsonArray("accepted_roster").spliterator(),false).map(v->v.getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(accepting.core.id.toString(),accepted.getFirst().get("playerid").getAsString());
|
||||
if(personal){assertFalse(values.has("party_id"));assertFalse(values.has("accepting_leader_character_id"));}
|
||||
else{assertEquals(party.id().toString(),values.get("party_id").getAsString());
|
||||
assertEquals(leader.core.record.id().toString(),values.get("accepting_leader_character_id").getAsString());}
|
||||
var expired=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.expired")).toList();assertEquals(personal?0:1,expired.size());
|
||||
if(!personal){var ended=expired.getFirst().getAsJsonObject("data");assertEquals("party_invalidated",ended.get("reason").getAsString());
|
||||
assertEquals(quest.id().toString(),ended.get("quest_id").getAsString());assertEquals(mode,ended.get("captured_mode").getAsString());
|
||||
assertEquals(values.getAsJsonArray("accepted_roster"),ended.getAsJsonArray("accepted_roster"));}
|
||||
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.trophy_earned")||row.get("type").getAsString().endsWith("adventure.reward_claimed")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CampQuestEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void registeredFullRerollExpiresOnlyTheUnfinishedCampAuthorityWithoutRewritingCapturedModeOrRewarding()throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
|
||||
var before=CombatExperienceNativeTest.record(f,actor);fixture.click(actor,fixture.open(actor),false);
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Accepted Same Camp;")));
|
||||
var quest=fixture.directory.active().getFirst();
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",quest.site().toString(),"party-required"});
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Updated adventure site")));
|
||||
assertEquals("individual-only",fixture.directory.findQuest(quest.id()).orElseThrow().participationMode().value());
|
||||
fixture.resetCatalog();assertTrue(fixture.directory.active().isEmpty());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
ReskillEventsNativeTest.await(f,()->{
|
||||
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.expired")).count()==1;}
|
||||
catch(Exception failure){throw new AssertionError(failure);}
|
||||
});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
|
||||
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.expired")).toList();
|
||||
assertEquals(1,rows.size(),"An actual registered full-day reset must export the acknowledged expiry of its original unfinished camp quest");
|
||||
var values=rows.getFirst().getAsJsonObject("data");
|
||||
assertEquals(quest.id().toString(),values.get("quest_id").getAsString());assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());
|
||||
assertEquals(quest.unit().toString(),values.get("unit_id").getAsString());assertEquals("individual-only",values.get("captured_mode").getAsString());
|
||||
assertEquals("full_day_reroll",values.get("reason").getAsString());assertEquals(quest.expiresAt().toString(),values.get("deadline").getAsString());
|
||||
assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.reward_claimed")
|
||||
||row.get("type").getAsString().endsWith("adventure.trophy_earned")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredGiverPersonalAcceptanceAndAbandonmentExportTheCapturedCharacterSessionAndQuestAuthority()throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;fixture.camp("party-available");fixture.giver();fixture.resetCatalog();f.activate(actor);
|
||||
var before=CombatExperienceNativeTest.record(f,actor);var menu=fixture.open(actor);
|
||||
assertEquals(org.bukkit.Material.PAPER,menu.getItem(0).getType());
|
||||
fixture.click(actor,menu,false);actor.core.location.setX(13);
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Accepted Same Camp;")));
|
||||
var quest=fixture.directory.active().getFirst();assertEquals(actor.core.record.id(),quest.unit());
|
||||
assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
var acceptedMenu=fixture.open(actor);fixture.click(actor,acceptedMenu,true);
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.equals("Adventure quest abandoned.")));
|
||||
assertTrue(fixture.directory.active().isEmpty());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
ReskillEventsNativeTest.await(f,()->{
|
||||
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.accepted")
|
||||
||row.get("type").getAsString().endsWith("adventure.quest.abandoned")).count()==2;}
|
||||
catch(Exception failure){throw new AssertionError(failure);}
|
||||
});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
|
||||
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.accepted")
|
||||
||row.get("type").getAsString().endsWith("adventure.quest.abandoned")).toList();
|
||||
assertEquals(2,rows.size(),"Actual registered personal camp acceptance and acknowledged abandonment must reach their owning JSONL records");
|
||||
for(var row:rows){var values=row.getAsJsonObject("data");
|
||||
assertEquals(quest.id().toString(),values.get("quest_id").getAsString());assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());
|
||||
assertEquals(quest.site().toString(),values.get("site_id").getAsString());assertEquals("solo",values.get("unit_kind").getAsString());
|
||||
assertEquals(quest.unit().toString(),values.get("unit_id").getAsString());assertEquals("party-available",values.get("captured_mode").getAsString());
|
||||
assertEquals(quest.expiresAt().toString(),values.get("deadline").getAsString());assertEquals("acknowledged",values.get("outcome").getAsString());
|
||||
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(quest.unit().toString(),row.get("characterid").getAsString());
|
||||
assertEquals(java.util.Set.of(quest.unit().toString()),java.util.stream.StreamSupport.stream(values.getAsJsonArray("accepted_roster").spliterator(),false)
|
||||
.map(v->v.getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
var accepted=rows.stream().filter(row->row.get("type").getAsString().endsWith("accepted")).findFirst().orElseThrow();
|
||||
assertEquals(12,accepted.getAsJsonObject("data").getAsJsonObject("location").get("x").getAsDouble());
|
||||
assertTrue(actor.messages.stream().filter(v->v.startsWith("Accepted Same Camp;")).noneMatch(v->v.contains("(12, 70, -8)")||v.contains("x=12")||v.contains(quest.site().toString())));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
|
||||
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CampRegistrationRevisionNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void acknowledgedModeChangeAdvancesRevisionWhileUnchangedStaleAndFailedWritesRetainIt() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id, actor.player);
|
||||
when(actor.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(actor.world.getName()).thenReturn("Revision Realm");
|
||||
var directory = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureQuests");
|
||||
f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"adventure-site", "place", "camp", "Original"});
|
||||
ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(v -> v.startsWith("Placed camp")));
|
||||
var original = directory.snapshot().sites().getFirst();
|
||||
f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"adventure-site", "mode", original.id().toString(), "individual-only"});
|
||||
ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(v -> v.startsWith("Updated adventure site")));
|
||||
var changed = directory.findSite(original.id()).orElseThrow();
|
||||
assertNotNull(changed.registrationRevision(), "Acknowledged camp mode publication must retain an independent revision");
|
||||
assertNotEquals(original.registrationRevision(), changed.registrationRevision());
|
||||
assertEquals(original.id(), changed.id());
|
||||
assertEquals(changed, new YamlAdventureQuestRepository(data).load().sites().getFirst());
|
||||
assertEquals(AdventureQuestDirectory.ModeStatus.UNCHANGED, directory.setSiteMode(changed.id(),
|
||||
changed.participationMode(), changed.participationMode()).get(5, java.util.concurrent.TimeUnit.SECONDS).status());
|
||||
assertEquals(AdventureQuestDirectory.ModeStatus.STALE, directory.setSiteMode(changed.id(),
|
||||
original.participationMode(), games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_REQUIRED)
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS).status());
|
||||
assertEquals(changed, directory.findSite(changed.id()).orElseThrow());
|
||||
var file = data.resolve("adventure-quests.yml"); var saved = java.nio.file.Files.readAllBytes(file);
|
||||
java.nio.file.Files.delete(file); java.nio.file.Files.createDirectory(file);
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> directory.setSiteMode(changed.id(),
|
||||
changed.participationMode(), games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_REQUIRED)
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertEquals(changed, directory.findSite(changed.id()).orElseThrow());
|
||||
java.nio.file.Files.delete(file); java.nio.file.Files.write(file, saved);
|
||||
var removed = directory.removeSite(changed.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(changed, removed); assertTrue(directory.findSite(changed.id()).isEmpty());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredCampAndUnloadedGiverPersistIndependentRevisionsBeforeSuccessAndReload() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); actor.allowed = true;
|
||||
f.players.put(actor.core.id, actor.player);
|
||||
UUID world = UUID.randomUUID(); when(actor.world.getUID()).thenReturn(world);
|
||||
when(actor.world.getName()).thenReturn("Revision Realm");
|
||||
var directory = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureQuests");
|
||||
assertTrue(f.adminCommand.getExecutor().onCommand(actor.player, f.adminCommand, "fstickadmin",
|
||||
new String[]{"adventure-site", "place", "camp", "--mode", "individual-only", "8", "16", "Same Name"}));
|
||||
ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(v -> v.startsWith("Placed camp")));
|
||||
var site = directory.snapshot().sites().getFirst();
|
||||
Map<?, ?> row = YamlConfiguration.loadConfiguration(data.resolve("adventure-quests.yml").toFile())
|
||||
.getMapList("sites").getFirst();
|
||||
assertNotNull(row.get("registration-revision"),
|
||||
"Actual registered camp save must persist its independent definition revision before success");
|
||||
UUID revision = UUID.fromString(row.get("registration-revision").toString());
|
||||
assertNotEquals(site.id(), revision);
|
||||
assertEquals(site, new YamlAdventureQuestRepository(data).load().sites().getFirst());
|
||||
assertTrue(f.adminCommand.getExecutor().onCommand(actor.player, f.adminCommand, "fstickadmin",
|
||||
new String[]{"quest-giver", "place", "Same Name"}));
|
||||
ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(v -> v.startsWith("Placed Quest Giver")));
|
||||
var giver = directory.snapshot().givers().getFirst();
|
||||
var yaml = YamlConfiguration.loadConfiguration(data.resolve("adventure-quests.yml").toFile());
|
||||
var giverRow = yaml.getMapList("givers").getFirst();
|
||||
assertNotNull(giverRow.get("registration-revision"));
|
||||
UUID giverRevision = UUID.fromString(giverRow.get("registration-revision").toString());
|
||||
assertNotEquals(giver.id(), giverRevision); assertNotEquals(revision, giverRevision);
|
||||
assertEquals(giver, new YamlAdventureQuestRepository(data).load().givers().getFirst());
|
||||
assertEquals(revision.toString(), yaml.getMapList("sites").getFirst().get("registration-revision"));
|
||||
assertEquals(2, yaml.getInt("schema-version"));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CampSiteEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void sameNameCampModeAndRemovalExportOnlyTheirActualAcknowledgedIdentityAndRetainFailedState() throws Exception {
|
||||
try (var f=new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id,actor.player);when(actor.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(actor.world.getName()).thenReturn("Camp Realm");
|
||||
var directory=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
|
||||
for(int i=0;i<2;i++){
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","camp","Same Name"});
|
||||
final int expected=i+1;ReskillEventsNativeTest.await(f,()->directory.snapshot().sites().size()==expected);
|
||||
}
|
||||
var first=directory.snapshot().sites().getFirst();var other=directory.snapshot().sites().getLast();
|
||||
var console=new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(console.port,"fstickadmin",new String[]{"adventure-site","mode",first.id().toString(),"individual-only"});
|
||||
ReskillEventsNativeTest.await(f,()->console.messages.stream().anyMatch(v->v.startsWith("Updated adventure site")));
|
||||
var changed=directory.findSite(first.id()).orElseThrow();
|
||||
f.adminCommand.execute(console.port,"fstickadmin",new String[]{"adventure-site","mode",first.id().toString(),"individual-only"});
|
||||
ReskillEventsNativeTest.await(f,()->console.messages.stream().anyMatch(v->v.contains("already individual-only")));
|
||||
var file=data.resolve("adventure-quests.yml");var bytes=java.nio.file.Files.readAllBytes(file);
|
||||
java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
|
||||
f.adminCommand.execute(console.port,"fstickadmin",new String[]{"adventure-site","mode",first.id().toString(),"party-required"});
|
||||
ReskillEventsNativeTest.await(f,()->console.messages.stream().anyMatch(v->v.startsWith("Adventure site mode save failed")));
|
||||
assertEquals(changed,directory.findSite(first.id()).orElseThrow());
|
||||
java.nio.file.Files.delete(file);java.nio.file.Files.write(file,bytes);
|
||||
f.adminCommand.execute(console.port,"fstickadmin",new String[]{"adventure-site","remove",first.id().toString()});
|
||||
ReskillEventsNativeTest.await(f,()->console.messages.stream().anyMatch(v->v.startsWith("Removed adventure site")));
|
||||
assertTrue(directory.findSite(first.id()).isEmpty());assertEquals(other,directory.findSite(other.id()).orElseThrow());
|
||||
ReskillEventsNativeTest.await(f,()->{
|
||||
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.site.mode_changed")
|
||||
||row.get("type").getAsString().endsWith("adventure.site.removed")).count()==2;}
|
||||
catch(Exception failure){throw new AssertionError(failure);}
|
||||
});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
|
||||
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->
|
||||
row.get("type").getAsString().endsWith("adventure.site.mode_changed")||row.get("type").getAsString().endsWith("adventure.site.removed")).toList();
|
||||
assertEquals(2,rows.size(),"Actual changed mode and removed camp must each export one owner outcome, never unchanged or failed copies");
|
||||
var mode=rows.stream().filter(row->row.get("type").getAsString().endsWith("mode_changed")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(first.id().toString(),mode.get("site_id").getAsString());
|
||||
assertEquals(changed.registrationRevision().toString(),mode.get("site_revision").getAsString());
|
||||
assertEquals("party-available",mode.get("mode_before").getAsString());assertEquals("individual-only",mode.get("mode_after").getAsString());
|
||||
assertEquals("future_acceptances",mode.get("application_scope").getAsString());assertEquals("console",mode.get("actor_kind").getAsString());
|
||||
var removed=rows.stream().filter(row->row.get("type").getAsString().endsWith("site.removed")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(changed.id().toString(),removed.get("site_id").getAsString());
|
||||
assertEquals(changed.registrationRevision().toString(),removed.get("site_revision").getAsString());
|
||||
assertEquals("individual-only",removed.get("mode_before").getAsString());assertEquals("not_applicable",removed.get("mode_after").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualRegisteredSavedCampExportsOriginalAdministratorRevisionLocationAndGeometry() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id, actor.player);
|
||||
UUID world = UUID.randomUUID(); when(actor.world.getUID()).thenReturn(world);
|
||||
when(actor.world.getName()).thenReturn("Camp Realm");
|
||||
f.activate(actor);
|
||||
var directory = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
|
||||
assertTrue(f.adminCommand.getExecutor().onCommand(actor.player,f.adminCommand,"fstickadmin",
|
||||
new String[]{"adventure-site","place","camp","--mode","party-required","20","60","Same Name"}));
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Placed camp")));
|
||||
var site=directory.snapshot().sites().getFirst();
|
||||
assertNotNull(site.registrationRevision());
|
||||
actor.core.location.setX(900);
|
||||
ReskillEventsNativeTest.await(f,()->{
|
||||
try{return ReskillEventsNativeTest.records(data).stream().anyMatch(row->row.get("type").getAsString().endsWith("adventure.site.registered")
|
||||
&&row.getAsJsonObject("data").get("site_id").getAsString().equals(site.id().toString()));}
|
||||
catch(Exception failure){throw new AssertionError(failure);}
|
||||
});
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
|
||||
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->
|
||||
row.get("type").getAsString().endsWith("adventure.site.registered")
|
||||
&& row.getAsJsonObject("data").get("site_id").getAsString().equals(site.id().toString())).toList();
|
||||
assertEquals(1,rows.size(),"Actual acknowledged registered camp administration must reach its feature-owned JSONL record");
|
||||
var row=rows.getFirst(); var values=row.getAsJsonObject("data");
|
||||
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());
|
||||
assertEquals(actor.core.record.id().toString(),row.get("characterid").getAsString());
|
||||
assertEquals("camp",values.get("site_kind").getAsString());
|
||||
assertEquals("Same Name",values.get("site_name").getAsString());
|
||||
assertEquals(site.registrationRevision().toString(),values.get("site_revision").getAsString());
|
||||
assertEquals("persisted",values.get("revision_capture_quality").getAsString());
|
||||
assertEquals(world.toString(),values.get("world_id").getAsString());
|
||||
assertEquals(12,values.get("x").getAsDouble()); assertEquals(70,values.get("y").getAsDouble());
|
||||
assertEquals(-8,values.get("z").getAsDouble());
|
||||
assertEquals("rectangle",values.getAsJsonObject("arena").get("shape").getAsString());
|
||||
assertEquals(20,values.getAsJsonObject("arena").get("x_blocks").getAsInt());
|
||||
assertEquals(60,values.getAsJsonObject("arena").get("z_blocks").getAsInt());
|
||||
assertEquals("party-required",values.get("mode_after").getAsString());
|
||||
assertEquals("acknowledged",values.get("outcome").getAsString());
|
||||
assertEquals(12,values.getAsJsonObject("location").get("x").getAsDouble());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
|
||||
|
||||
import games.dmg.fantasystick.adventure.FishingDirectory;
|
||||
import games.dmg.fantasystick.adventure.FishingState;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import org.bukkit.entity.Vindicator;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ChampionAttemptEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void realSoloChampionNativeConfigurationCancelledOutsiderGenuineRepresentationAndDuplicateDeathUseSoleHeadRedemptionAndClaim()throws Exception{
|
||||
try(var championFixture=new ChampionAuditNativeFixture(data)){
|
||||
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
|
||||
var before=CombatExperienceNativeTest.record(f,actor);fixture.click(actor,fixture.open(actor),false);
|
||||
ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();var site=fixture.directory.findSite(quest.site()).orElseThrow();fixture.pulse();
|
||||
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(Vindicator.class::isInstance));var champion=fixture.spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();
|
||||
assertEquals(100,champion.getHealth());assertEquals(100,champion.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH).getBaseValue());assertEquals(8,champion.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE).getBaseValue());
|
||||
assertEquals(net.kyori.adventure.text.Component.text("Daybreak Champion"),champion.customName());assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION,fixture.controller.encounterOrigin(champion).orElseThrow().kind());
|
||||
String attempt=champion.getPersistentDataContainer().get(new org.bukkit.NamespacedKey(f.plugin,"adventure_attempt_id"),org.bukkit.persistence.PersistentDataType.STRING);assertNotNull(attempt);
|
||||
var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
|
||||
var rejected=new EntityDamageByEntityEvent(outsider.player,champion,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(rejected);assertTrue(rejected.isCancelled());
|
||||
var cancelled=new EntityDamageByEntityEvent(actor.player,champion,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
|
||||
var hit=new EntityDamageByEntityEvent(actor.player,champion,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(champion.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
|
||||
var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new ArrayList<>(),7);f.dispatcher.accept(death);f.dispatcher.accept(death);assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());
|
||||
var trophies=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty()&&trophies.trophies(quest.unit()).size()==1);
|
||||
assertEquals(before,CombatExperienceNativeTest.record(f,actor));var trophy=trophies.trophies(quest.unit()).getFirst();assertEquals(FishingState.TrophyType.CHAMPION,trophy.type());assertEquals(quest.id(),trophy.quest());assertEquals(java.util.Set.of(quest.unit()),trophy.rewards().keySet());
|
||||
var menu=fixture.open(actor);fixture.click(actor,menu,CampAttemptEventsNativeTest.slot(menu,"Turn in quest items"),false);menu=actor.inventories.getLast();fixture.click(actor,menu,CampAttemptEventsNativeTest.slot(menu,"Redeem Daybreak Champion Head"),false);
|
||||
ReskillEventsNativeTest.await(f,()->trophies.trophies(quest.unit()).isEmpty()&&!trophies.unclaimed(quest.unit()).equals(FishingState.Reward.empty()));assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
menu=fixture.open(actor);fixture.click(actor,menu,CampAttemptEventsNativeTest.slot(menu,"Turn in quest items"),false);menu=actor.inventories.getLast();fixture.click(actor,menu,CampAttemptEventsNativeTest.slot(menu,"Claim earned quest rewards"),false);
|
||||
ReskillEventsNativeTest.await(f,()->trophies.unclaimed(quest.unit()).equals(FishingState.Reward.empty()));var paid=CombatExperienceNativeTest.record(f,actor);assertEquals(before.experience().add(quest.rewardXp()),paid.experience());assertEquals(before.coins().add(quest.rewardCoins()),paid.coins());
|
||||
ReskillEventsNativeTest.await(f,()->{
|
||||
try{return rows("adventure.trophy_earned").size()==1&&rows("adventure.trophy_redeemed").size()==1&&rows("adventure.reward_claimed").size()==1
|
||||
&&rows("adventure.champion.spawned").size()==1&&rows("adventure.champion.defeated").size()==1;}catch(Exception failure){throw new AssertionError(failure);}
|
||||
});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var starts=rows("adventure.champion.spawned");var clears=rows("adventure.champion.defeated");
|
||||
assertEquals(1,starts.size(),"Actual configured loaded-valid champion must export exactly one independent transient start");assertEquals(1,clears.size());
|
||||
var start=starts.getFirst().getAsJsonObject("data");var clear=clears.getFirst().getAsJsonObject("data");assertEquals(attempt,start.get("attempt_id").getAsString());assertEquals(attempt,clear.get("attempt_id").getAsString());
|
||||
assertEquals(site.registrationRevision().toString(),start.get("site_revision").getAsString());assertEquals("native_transient_not_persisted",start.get("attempt_capture_quality").getAsString());assertEquals("succeeded",start.get("native_outcome").getAsString());
|
||||
assertEquals(100,start.get("configured_health").getAsDouble());assertEquals(8,start.get("configured_damage").getAsDouble());assertEquals(champion.getUniqueId().toString(),clear.get("final_enemy_id").getAsString());
|
||||
assertEquals("Daybreak Champion",start.get("champion_name").getAsString());var nativeSource=clear.getAsJsonObject("defeat_source");
|
||||
assertEquals(actor.core.id.toString(),nativeSource.get("causing_entity_id").getAsString());assertEquals(actor.core.id.toString(),nativeSource.get("direct_entity_id").getAsString());
|
||||
assertEquals(actor.core.id.toString(),nativeSource.get("killer_player_id").getAsString());assertEquals(before.id().toString(),nativeSource.get("killer_character_id").getAsString());
|
||||
assertEquals("captured_native_death",nativeSource.get("capture_quality").getAsString());
|
||||
assertTrue(rows("adventure.champion.started").isEmpty());assertTrue(rows("adventure.champion.cleared").isEmpty());
|
||||
assertEquals(1,clear.get("represented_unit_total").getAsInt());assertEquals(quest.id().toString(),clear.getAsJsonArray("represented_quest_ids").get(0).getAsString());assertEquals(0,clear.get("remaining_entity_count").getAsInt());
|
||||
assertEquals("existing_trophy_receipt_and_beneficiary_claim",clear.get("reward_owner").getAsString());assertTrue(rows("adventure.champion.reset").isEmpty());
|
||||
}
|
||||
}
|
||||
@Test void actualChunkUnloadAndDisableResetDistinctTransientChampionsThenReplayAuditWithoutRestoringEncounterAuthority()throws Exception{
|
||||
try(var championFixture=new ChampionAuditNativeFixture(data)){
|
||||
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
|
||||
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);fixture.pulse();
|
||||
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(Vindicator.class::isInstance).count()==1);var first=fixture.spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();
|
||||
var key=new org.bukkit.NamespacedKey(f.plugin,"adventure_attempt_id");String original=first.getPersistentDataContainer().get(key,org.bukkit.persistence.PersistentDataType.STRING);
|
||||
f.dispatcher.accept(new org.bukkit.event.world.ChunkUnloadEvent(actor.world.getChunkAt(0,-1),false));assertFalse(first.isValid());assertFalse(fixture.controller.hasActiveEncounter());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());
|
||||
fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(Vindicator.class::isInstance).count()==2);var second=fixture.spawned.stream().filter(Vindicator.class::isInstance).skip(1).findFirst().orElseThrow();String successor=second.getPersistentDataContainer().get(key,org.bukkit.persistence.PersistentDataType.STRING);assertNotEquals(original,successor);assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.spawned").size()==2&&rows("adventure.champion.reset").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
|
||||
// Existing immutable shutdown intent is audit-only, never persisted native authority.
|
||||
f.plugin.setEnabled(true);ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.reset").size()==2;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
assertFalse(((games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController")).hasActiveEncounter());
|
||||
assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
assertEquals(quest,((games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests")).findQuest(quest.id()).orElseThrow());
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var resets=rows("adventure.champion.reset");
|
||||
assertEquals(2,resets.size(),"Actual chunk unload and disable must record their distinct transient champion resets, including immutable shutdown replay");
|
||||
assertEquals(java.util.Set.of(original,successor),resets.stream().map(row->row.getAsJsonObject("data").get("attempt_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(java.util.Set.of("chunk_unload","plugin_disable"),resets.stream().map(row->row.getAsJsonObject("data").get("reason").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
for(var row:resets){var values=row.getAsJsonObject("data");assertEquals("succeeded",values.get("native_outcome").getAsString());assertEquals(0,values.get("remaining_entity_count").getAsInt());assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());}
|
||||
assertTrue(rows("adventure.champion.defeated").isEmpty());assertTrue(rows("adventure.champion.cleared").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
|
||||
}
|
||||
}
|
||||
@Test void returnedSpawnWithCancelledNativeAdditionCannotClaimSuccessfulChampionMaterializationClearOrTrophy()throws Exception{
|
||||
try(var championFixture=new ChampionAuditNativeFixture(data)){
|
||||
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
|
||||
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);
|
||||
fixture.nativeSpawnOutcome=entity->{if(entity instanceof Vindicator)when(entity.isValid()).thenReturn(false);};fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(Vindicator.class::isInstance));
|
||||
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.spawned").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
|
||||
var starts=rows("adventure.champion.spawned");assertEquals(1,starts.size(),"A returned cancelled native spawn still needs one truthful observed champion attempt, not silence or invented success");
|
||||
var values=starts.getFirst().getAsJsonObject("data");assertEquals("not_materialized",values.get("native_outcome").getAsString());assertEquals("native_not_materialized",values.get("outcome").getAsString());assertEquals(0,values.getAsJsonObject("native_materialization").get("remaining_native_entities").getAsInt());
|
||||
assertTrue(rows("adventure.champion.defeated").isEmpty());assertTrue(rows("adventure.champion.cleared").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
|
||||
}
|
||||
}
|
||||
private java.util.List<com.google.gson.JsonObject> rows(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Vindicator;
|
||||
|
||||
/** Existing supported-interface fixture, extended only with champion health/attribute/name ports. */
|
||||
final class ChampionAuditNativeFixture implements AutoCloseable {
|
||||
final CampAuditNativeFixture base;
|
||||
ChampionAuditNativeFixture(Path data)throws Exception{
|
||||
base=new CampAuditNativeFixture(data);base.nativeSpawnSetup=ChampionAuditNativeFixture::championPorts;
|
||||
playerPorts(base.actor);
|
||||
}
|
||||
static void playerPorts(FantasyStickLifecycleNativeTest.Actor actor){doReturn(org.bukkit.entity.EntityType.PLAYER).when(actor.player).getType();}
|
||||
private static void championPorts(LivingEntity entity){
|
||||
if(!(entity instanceof Vindicator))return;
|
||||
var health=new AtomicReference<Double>(20.0);var name=new AtomicReference<net.kyori.adventure.text.Component>();
|
||||
when(entity.getHealth()).thenAnswer(call->health.get());doAnswer(call->{health.set(call.getArgument(0));return null;}).when(entity).setHealth(org.mockito.ArgumentMatchers.anyDouble());
|
||||
var maximum=attribute(20);var damage=attribute(5);
|
||||
when(entity.getAttribute(Attribute.MAX_HEALTH)).thenReturn(maximum);when(entity.getAttribute(Attribute.ATTACK_DAMAGE)).thenReturn(damage);
|
||||
when(entity.customName()).thenAnswer(call->name.get());doAnswer(call->{name.set(call.getArgument(0));return null;}).when(entity).customName(org.mockito.ArgumentMatchers.any(net.kyori.adventure.text.Component.class));
|
||||
}
|
||||
private static AttributeInstance attribute(double initial){
|
||||
var value=new AtomicReference<Double>(initial);var attribute=mock(AttributeInstance.class);
|
||||
when(attribute.getValue()).thenAnswer(call->value.get());when(attribute.getBaseValue()).thenAnswer(call->value.get());
|
||||
doAnswer(call->{value.set(call.getArgument(0));return null;}).when(attribute).setBaseValue(org.mockito.ArgumentMatchers.anyDouble());return attribute;
|
||||
}
|
||||
void champion(String mode)throws Exception{
|
||||
var f=base.f;long count=base.actor.messages.stream().filter(value->value.startsWith("Placed champion")).count();
|
||||
f.adminCommand.execute(base.actor.player,"fstickadmin",new String[]{"adventure-site","place","champion","--mode",mode,"Same Champion"});
|
||||
ReskillEventsNativeTest.await(f,()->base.actor.messages.stream().filter(value->value.startsWith("Placed champion")).count()==count+1);
|
||||
}
|
||||
@Override public void close()throws java.io.IOException{base.close();}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
|
||||
|
||||
import games.dmg.fantasystick.events.ChampionEventBridge;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ChampionAuditSafetyReplayNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void rejectedRegisteredCreationQuestNativeAndConfigurationRecordsReplayEveryOriginalPropertyOfflineWithoutGameplayAccess()throws Exception{
|
||||
try(var championFixture=new ChampionAuditNativeFixture(data)){
|
||||
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
|
||||
((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();
|
||||
championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();fixture.pulse();
|
||||
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(org.bukkit.entity.Vindicator.class::isInstance));var champion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
|
||||
String key=AdventureQuestSettings.CHAMPION_HEALTH;f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key});f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,"200"});f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,"225"});actor.core.location.setX(19);
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved "+key+"=225")));assertEquals(100,champion.getHealth());
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
|
||||
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(champion.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
|
||||
var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new java.util.ArrayList<>(),0);f.dispatcher.accept(death);f.dispatcher.accept(death);
|
||||
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty()&&trophies.trophies(quest.unit()).size()==1);var trophy=trophies.trophies(quest.unit()).getFirst();assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");drain(f,"configurationDrain");var original=intents(data.resolve("champion-event-intents"));assertEquals(7,original.size());assertTrue(championRows().isEmpty());
|
||||
var config=Files.readAllBytes(data.resolve("config.yml"));var adventure=Files.readAllBytes(data.resolve("adventure-quests.yml"));var inventory=actor.core.snapshot();var messages=List.copyOf(actor.messages);int spawnCount=fixture.spawned.size();
|
||||
f.players.clear();clearInvocations(actor.player);f.plugin.setEnabled(true);ReskillEventsNativeTest.await(f,()->{try{return championRows().size()==7;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
assertArrayEquals(config,Files.readAllBytes(data.resolve("config.yml")));assertArrayEquals(adventure,Files.readAllBytes(data.resolve("adventure-quests.yml")));assertArrayEquals(inventory,actor.core.snapshot());assertEquals(messages,actor.messages);assertEquals(spawnCount,fixture.spawned.size());verifyNoInteractions(actor.player);
|
||||
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(before,storage.find(before.id()).get().orElseThrow());
|
||||
var reloaded=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");assertEquals(List.of(trophy),reloaded.trophies(quest.unit()));assertFalse(((games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController")).hasActiveEncounter());
|
||||
var rows=championRows();for(var intent:original){for(var property:intent.stringPropertyNames())if(intent.getProperty(property).equals("l"))intent.setProperty(property,"j[]");assertOriginal(intent,rows.stream().filter(row->row.get("id").getAsString().equals(intent.getProperty("id"))).findFirst().orElseThrow());}
|
||||
var changes=rows.stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).map(row->row.getAsJsonObject("data")).toList();assertEquals(java.util.Map.of("100","200","200","225"),changes.stream().collect(java.util.stream.Collectors.toMap(value->value.get("before").getAsString(),value->value.get("after").getAsString())));
|
||||
var bridge=(ChampionEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"championAuditEvents");bridge.replay();bridge.replay();f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");assertEquals(7,championRows().size());assertEquals(messages,actor.messages);verifyNoInteractions(actor.player);
|
||||
}
|
||||
}
|
||||
@Test void deniedInvalidLockedClosedAndUnchangedChampionSettingsCannotPublishPositiveHistory()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var denied=new EquipmentAdminCommandNativeTest.Sender();denied.allowed=false;
|
||||
var settings=(AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");String key=AdventureQuestSettings.CHAMPION_DAMAGE;
|
||||
f.adminCommand.execute(denied.port,"fstickadmin",new String[]{"config",key});f.adminCommand.execute(denied.port,"fstickadmin",new String[]{"config",key,"12"});
|
||||
for(var invalid:List.of("0","2049","NaN","1.5"))f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,invalid});f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"12","extra"});assertEquals("8",settings.value(key));
|
||||
f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"8"});ReskillEventsNativeTest.await(f,()->sender.messages.stream().anyMatch(value->value.startsWith("Saved "+key+"=8")));var bytes=Files.readAllBytes(data.resolve("config.yml"));
|
||||
try(var channel=FileChannel.open(data.resolve(".config.lock"),StandardOpenOption.CREATE,StandardOpenOption.WRITE);var lock=channel.lock()){
|
||||
assertTrue(lock.isValid());f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"12"});ReskillEventsNativeTest.await(f,()->sender.messages.contains("Adventure quest configuration save failed; effective values are unchanged."));assertEquals("8",settings.value(key));assertArrayEquals(bytes,Files.readAllBytes(data.resolve("config.yml")));
|
||||
}
|
||||
settings.close();sender.messages.clear();f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"16"});ReskillEventsNativeTest.await(f,()->sender.messages.contains("Adventure quest configuration save failed; effective values are unchanged."));assertEquals("8",settings.value(key));assertArrayEquals(bytes,Files.readAllBytes(data.resolve("config.yml")));
|
||||
f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key});ReskillEventsNativeTest.await(f,()->{try{return championRows().size()==1;}catch(Exception failure){throw new AssertionError(failure);}});f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");assertEquals(1,championRows().size());assertTrue(championRows().getFirst().get("type").getAsString().endsWith("config.read"));
|
||||
}
|
||||
}
|
||||
private List<com.google.gson.JsonObject> championRows()throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->{var values=row.getAsJsonObject("data");return values.has("site_kind")&&values.get("site_kind").getAsString().equals("champion")||values.has("key")&&ChampionEventBridge.ownsSetting(values.get("key").getAsString());}).toList();}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ChampionConfigurationEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
private record Setting(String key,String before,String after,String scope){}
|
||||
private static final List<Setting> SETTINGS=List.of(
|
||||
new Setting(AdventureQuestSettings.CHAMPION_HEALTH,"100","200","future_attempts"),
|
||||
new Setting(AdventureQuestSettings.CHAMPION_DAMAGE,"8","12","future_attempts"),
|
||||
new Setting(AdventureQuestSettings.CHAMPION_REWARD_XP,"150","225","future_acceptances"),
|
||||
new Setting(AdventureQuestSettings.CHAMPION_REWARD_COINS,"40","60","future_acceptances"));
|
||||
@Test void allFourRegisteredChampionReadsAndPublicationsKeepOriginalRequestContextAndCurrentQuestDefinitionAndNativeStats()throws Exception{
|
||||
try(var championFixture=new ChampionAuditNativeFixture(data)){
|
||||
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
|
||||
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);
|
||||
var quest=fixture.directory.active().getFirst();var catalog=fixture.directory.snapshot().catalog();var site=fixture.directory.findSite(quest.site()).orElseThrow();
|
||||
var character=CombatExperienceNativeTest.record(f,actor);fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(org.bukkit.entity.Vindicator.class::isInstance));
|
||||
var nativeChampion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
|
||||
var settings=(AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");
|
||||
String session=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).orElseThrow().session().toString();
|
||||
for(var setting:SETTINGS){
|
||||
actor.core.location.setX(12);f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key()});
|
||||
assertTrue(actor.messages.stream().anyMatch(value->value.startsWith(setting.key()+"="+setting.before()+" ")));
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key(),setting.after()});actor.core.location.setX(77);
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved "+setting.key()+"="+setting.after()+" for later")));
|
||||
assertEquals(setting.after(),settings.value(setting.key()));actor.core.location.setX(12);f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key()});
|
||||
// These refused/unchanged paths must not become acknowledged configuration changes.
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key(),setting.after()});
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key(),"-1"});
|
||||
actor.allowed=false;f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key()});f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key(),setting.before()});actor.allowed=true;
|
||||
}
|
||||
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(catalog,fixture.directory.snapshot().catalog());assertEquals(site,fixture.directory.findSite(quest.site()).orElseThrow());assertEquals(character,CombatExperienceNativeTest.record(f,actor));
|
||||
assertEquals(100,nativeChampion.getHealth());assertEquals(100,nativeChampion.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH).getBaseValue());assertEquals(8,nativeChampion.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE).getBaseValue());
|
||||
ReskillEventsNativeTest.await(f,()->{try{return ownedRows().size()==12;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var rows=ownedRows();
|
||||
assertEquals(12,rows.size(),"All four actual registered champion read/change/read histories must be owned and exported, with no refused or unchanged duplicates");
|
||||
for(var setting:SETTINGS){var owned=rows.stream().filter(row->row.getAsJsonObject("data").get("key").getAsString().equals(setting.key())).toList();
|
||||
var reads=owned.stream().filter(row->row.get("type").getAsString().endsWith("config.read")).toList();assertEquals(2,reads.size());assertEquals(java.util.Set.of(setting.before(),setting.after()),reads.stream().map(row->row.getAsJsonObject("data").get("effective").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
var changes=owned.stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).toList();assertEquals(1,changes.size());var values=changes.getFirst().getAsJsonObject("data");assertEquals(setting.before(),values.get("before").getAsString());assertEquals(setting.after(),values.get("after").getAsString());
|
||||
for(var row:owned){var valuesAll=row.getAsJsonObject("data");assertEquals(setting.before(),valuesAll.get("default").getAsString());assertEquals(setting.scope(),valuesAll.get("application_scope").getAsString());assertEquals("champion",valuesAll.get("feature").getAsString());
|
||||
assertEquals(12,valuesAll.getAsJsonObject("location").get("x").getAsDouble());assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(character.id().toString(),row.get("characterid").getAsString());assertEquals(session,valuesAll.get("session_id").getAsString());}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test void queuedChampionSettingPublicationsCaptureAcknowledgedBeforeRatherThanRequestSnapshotAndFailedSaveHasNoHistory()throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);var settings=(AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",AdventureQuestSettings.CHAMPION_HEALTH,"200"});f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",AdventureQuestSettings.CHAMPION_HEALTH,"225"});
|
||||
ReskillEventsNativeTest.await(f,()->settings.value(AdventureQuestSettings.CHAMPION_HEALTH).equals("225"));
|
||||
var file=data.resolve("config.yml");var bytes=java.nio.file.Files.readAllBytes(file);java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
|
||||
int messages=actor.messages.size();f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",AdventureQuestSettings.CHAMPION_HEALTH,"300"});ReskillEventsNativeTest.await(f,()->actor.messages.subList(messages,actor.messages.size()).stream().anyMatch(value->value.contains("save failed")));
|
||||
assertEquals("225",settings.value(AdventureQuestSettings.CHAMPION_HEALTH));assertEquals(before,CombatExperienceNativeTest.record(f,actor));java.nio.file.Files.delete(file);java.nio.file.Files.write(file,bytes);
|
||||
ReskillEventsNativeTest.await(f,()->{try{return ownedRows().stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).count()==2;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var changes=ownedRows().stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).toList();
|
||||
assertEquals(2,changes.size(),"Exactly two real queued champion publications, never the failed third save, own history");
|
||||
assertEquals(java.util.Map.of("100","200","200","225"),changes.stream().map(row->row.getAsJsonObject("data")).collect(java.util.stream.Collectors.toMap(value->value.get("before").getAsString(),value->value.get("after").getAsString())));
|
||||
}
|
||||
}
|
||||
private java.util.List<com.google.gson.JsonObject> ownedRows()throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.getAsJsonObject("data").has("key")&&SETTINGS.stream().anyMatch(setting->setting.key().equals(row.getAsJsonObject("data").get("key").getAsString()))).toList();}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class ChampionFaultEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void closedOrUnwritableChampionAuditCannotPreventActualQuestNativeClearOrExistingHeadPersistence(boolean closed)throws Exception{
|
||||
if(!closed)java.nio.file.Files.writeString(data.resolve("champion-event-intents"),"not a directory");
|
||||
try(var championFixture=new ChampionAuditNativeFixture(data)){
|
||||
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;
|
||||
if(closed)((games.dmg.fantasystick.events.ChampionEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"championAuditEvents")).close();
|
||||
championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();fixture.pulse();
|
||||
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(org.bukkit.entity.Vindicator.class::isInstance));var champion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
|
||||
var source=source(actor);var hit=hit(actor,champion,source);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new java.util.ArrayList<>(),0);f.dispatcher.accept(death);f.dispatcher.accept(death);
|
||||
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty()&&trophies.trophies(quest.unit()).size()==1);assertEquals(quest.id(),trophies.trophies(quest.unit()).getFirst().quest());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.trophy_earned").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
|
||||
assertTrue(rows("adventure.champion.spawned").isEmpty());assertTrue(rows("adventure.champion.defeated").isEmpty());assertEquals(1,rows("adventure.trophy_earned").size());
|
||||
}
|
||||
}
|
||||
@Test void failedTrophyPersistenceDoesNotCompleteOrPayOriginalQuestDespiteGenuineNativeDefeatRecord()throws Exception{
|
||||
try(var championFixture=new ChampionAuditNativeFixture(data)){
|
||||
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
|
||||
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(org.bukkit.entity.Vindicator.class::isInstance));var champion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
|
||||
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");trophies.close();var source=source(actor);f.dispatcher.accept(hit(actor,champion,source));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(champion,source,new java.util.ArrayList<>(),0));
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.defeated").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertTrue(trophies.trophies(quest.unit()).isEmpty());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
assertTrue(rows("adventure.trophy_earned").isEmpty());assertTrue(rows("adventure.reward_claimed").isEmpty());f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
|
||||
}
|
||||
}
|
||||
@Test void absenceResetsThenUnrepresentedNativeDefeatLeavesQuestAvailableAndMissingOriginalNameUnavailable()throws Exception{
|
||||
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-site-reset-seconds",0);config.save(data.resolve("config.yml").toFile());
|
||||
try(var championFixture=new ChampionAuditNativeFixture(data)){
|
||||
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();
|
||||
fixture.nativeSpawnOutcome=entity->{if(entity instanceof org.bukkit.entity.Vindicator)doThrow(new IllegalStateException("original native name unavailable")).when(entity).customName();};fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).count()==1);var first=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
|
||||
actor.core.location.setX(77);fixture.pulse();fixture.pulse();assertFalse(first.isValid());assertFalse(fixture.controller.hasActiveEncounter());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());
|
||||
actor.core.location.setX(12);fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).count()==2);var second=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).skip(1).findFirst().orElseThrow();
|
||||
var nativeSource=mock(org.bukkit.damage.DamageSource.class);f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(second,nativeSource,new java.util.ArrayList<>(),0));
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.spawned").size()==2&&rows("adventure.champion.reset").size()==1&&rows("adventure.champion.defeated").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(before,CombatExperienceNativeTest.record(f,actor));assertTrue(rows("adventure.trophy_earned").isEmpty());
|
||||
var reset=rows("adventure.champion.reset").getFirst().getAsJsonObject("data");assertEquals("no_eligible_accepted_character_remained",reset.get("reason").getAsString());var defeat=rows("adventure.champion.defeated").getFirst().getAsJsonObject("data");assertEquals(0,defeat.get("represented_unit_total").getAsInt());assertEquals("unavailable",defeat.getAsJsonObject("defeat_source").get("killer_context_capture_quality").getAsString());
|
||||
for(var row:rows("adventure.champion.spawned")){var values=row.getAsJsonObject("data");assertFalse(values.has("champion_name"));assertEquals("unavailable",values.get("champion_name_capture_quality").getAsString());assertEquals(100,values.get("configured_health").getAsDouble());}
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
|
||||
}
|
||||
}
|
||||
@Test void failedNativeSpawnAndFailedSavedAcceptanceCannotInventChampionOrQuestSuccess()throws Exception{
|
||||
try(var championFixture=new ChampionAuditNativeFixture(data)){
|
||||
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
|
||||
var file=data.resolve("adventure-quests.yml");var bytes=java.nio.file.Files.readAllBytes(file);java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
|
||||
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.contains("Quest acceptance failed; retained state is unchanged."));assertTrue(fixture.directory.active().isEmpty());assertTrue(rows("adventure.quest.accepted").isEmpty());java.nio.file.Files.delete(file);java.nio.file.Files.write(file,bytes);
|
||||
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();
|
||||
fixture.nativeSpawnSetup=entity->{if(entity instanceof org.bukkit.entity.Vindicator)throw new IllegalStateException("native spawn rejected before configuration");};fixture.pulse();
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.spawned").size()==1&&rows("adventure.champion.reset").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
assertFalse(fixture.controller.hasActiveEncounter());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(before,CombatExperienceNativeTest.record(f,actor));var value=rows("adventure.champion.spawned").getFirst().getAsJsonObject("data");
|
||||
assertEquals("failed",value.get("native_outcome").getAsString());assertEquals("native_failed",value.get("outcome").getAsString());assertEquals("native_spawn_or_configuration_failed",value.get("reason").getAsString());assertFalse(value.has("configured_health"));assertFalse(value.has("champion_name"));
|
||||
assertEquals(1,rows("adventure.quest.accepted").size());assertTrue(rows("adventure.champion.defeated").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
|
||||
}
|
||||
}
|
||||
private static org.bukkit.damage.DamageSource source(FantasyStickLifecycleNativeTest.Actor actor){var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);return source;}
|
||||
private static org.bukkit.event.entity.EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.entity.LivingEntity target,org.bukkit.damage.DamageSource source){var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,target,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(target.getLastDamageCause()).thenReturn(hit);return hit;}
|
||||
private java.util.List<com.google.gson.JsonObject> rows(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static games.dmg.fantasystick.CampAttemptEventsNativeTest.slot;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.adventure.FishingState;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ChampionPartyEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void mutedSharedChampionClearPreservesUnrepresentedCancelledSoloAndAddedMemberThenRedemptionPaysOnlyOfflineCarriersFrozenMemberShare()throws Exception{
|
||||
try(var championFixture=new ChampionAuditNativeFixture(data)){
|
||||
var fixture=championFixture.base;var f=fixture.f;var leader=fixture.actor;f.activate(leader);
|
||||
var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(leader.world);f.players.put(member.core.id,member.player);f.activate(member);ChampionAuditNativeFixture.playerPorts(member);
|
||||
var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(leader.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,parties.invite(leader.core.id,party.id(),member.core.id).orElseThrow().id()));
|
||||
championFixture.champion("party-available");fixture.giver();fixture.resetCatalog();fixture.click(leader,fixture.open(leader),true);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();
|
||||
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(leader.world);f.players.put(solo.core.id,solo.player);f.activate(solo);fixture.click(solo,fixture.open(solo),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==2);
|
||||
var soloQuest=fixture.directory.active().stream().filter(value->value.unitType()==AdventureQuestState.UnitType.SOLO).findFirst().orElseThrow();
|
||||
var added=new FantasyStickLifecycleNativeTest.Actor(data);added.core.location.setWorld(leader.world);f.players.put(added.core.id,added.player);f.activate(added);assertTrue(parties.accept(added.core.id,parties.invite(leader.core.id,party.id(),added.core.id).orElseThrow().id()));assertFalse(quest.snapshot().contains(added.core.record.id()));
|
||||
for(var holder:java.util.List.of(leader,member)){assertTrue(f.playerCommand.execute(holder.player,"fstick",new String[]{"feedback","off"}));ReskillEventsNativeTest.await(f,()->holder.messages.contains("Fantasy feedback is now off."));}
|
||||
var preferences=(games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"feedbackPreferences");assertFalse(preferences.enabled(leader.core.id));assertFalse(preferences.enabled(member.core.id));
|
||||
var leaderBefore=CombatExperienceNativeTest.record(f,leader);var memberBefore=CombatExperienceNativeTest.record(f,member);var soloBefore=CombatExperienceNativeTest.record(f,solo);var addedBefore=CombatExperienceNativeTest.record(f,added);
|
||||
fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(org.bukkit.entity.Vindicator.class::isInstance));var champion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(member.player);when(source.getDirectEntity()).thenReturn(member.player);
|
||||
var cancelledSource=mock(org.bukkit.damage.DamageSource.class);when(cancelledSource.getCausingEntity()).thenReturn(solo.player);when(cancelledSource.getDirectEntity()).thenReturn(solo.player);
|
||||
var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(solo.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,cancelledSource,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
|
||||
var rejected=new org.bukkit.event.entity.EntityDamageByEntityEvent(added.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(rejected);assertTrue(rejected.isCancelled());
|
||||
doReturn(false).when(leader.player).isOnline();var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(member.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(champion.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
|
||||
var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new java.util.ArrayList<>(),0);f.dispatcher.accept(death);f.dispatcher.accept(death);
|
||||
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");ReskillEventsNativeTest.await(f,()->fixture.directory.findQuest(quest.id()).isEmpty()&&trophies.trophies(leaderBefore.id()).size()==1);
|
||||
var trophy=trophies.trophies(leaderBefore.id()).getFirst();assertEquals(FishingState.TrophyType.CHAMPION,trophy.type());assertEquals(quest.id(),trophy.quest());assertEquals(Set.of(memberBefore.id()),trophy.rewards().keySet());assertEquals(quest.rewardXp(),trophy.total().xp());assertEquals(quest.rewardCoins(),trophy.total().coins());
|
||||
assertEquals(soloQuest,fixture.directory.findQuest(soloQuest.id()).orElseThrow());assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));assertEquals(soloBefore,CombatExperienceNativeTest.record(f,solo));assertEquals(addedBefore,CombatExperienceNativeTest.record(f,added));
|
||||
doReturn(true).when(leader.player).isOnline();var menu=fixture.open(leader);fixture.click(leader,menu,slot(menu,"Turn in quest items"),false);menu=leader.inventories.getLast();fixture.click(leader,menu,slot(menu,"Redeem Daybreak Champion Head"),false);
|
||||
ReskillEventsNativeTest.await(f,()->trophies.trophies(leaderBefore.id()).isEmpty()&&!trophies.unclaimed(memberBefore.id()).equals(FishingState.Reward.empty()));assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));
|
||||
menu=fixture.open(member);fixture.click(member,menu,slot(menu,"Turn in quest items"),false);menu=member.inventories.getLast();fixture.click(member,menu,slot(menu,"Claim earned quest rewards"),false);ReskillEventsNativeTest.await(f,()->trophies.unclaimed(memberBefore.id()).equals(FishingState.Reward.empty()));
|
||||
var paid=CombatExperienceNativeTest.record(f,member);assertEquals(memberBefore.experience().add(quest.rewardXp()),paid.experience());assertEquals(memberBefore.coins().add(quest.rewardCoins()),paid.coins());assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(soloBefore,CombatExperienceNativeTest.record(f,solo));assertEquals(addedBefore,CombatExperienceNativeTest.record(f,added));
|
||||
ReskillEventsNativeTest.await(f,()->{try{var rows=ReskillEventsNativeTest.records(data);return java.util.stream.Stream.of("adventure.champion.spawned","adventure.champion.defeated","adventure.trophy_earned","adventure.trophy_redeemed","adventure.reward_claimed").allMatch(type->rows.stream().filter(row->row.get("type").getAsString().endsWith(type)).count()==1);}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
var rows=ReskillEventsNativeTest.records(data);var defeat=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.champion.defeated")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(trophy.source().toString(),defeat.get("attempt_id").getAsString());assertEquals(1,defeat.get("represented_unit_total").getAsInt());var represented=defeat.getAsJsonArray("represented_units").get(0).getAsJsonObject();assertEquals(quest.id().toString(),represented.get("quest_id").getAsString());assertEquals(party.id().toString(),represented.get("unit_id").getAsString());assertEquals(memberBefore.id().toString(),defeat.getAsJsonObject("defeat_source").get("killer_character_id").getAsString());
|
||||
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.quest.expired")));assertFalse(preferences.enabled(member.core.id));f.plugin.setEnabled(false);f.awaitWorkers();games.dmg.fantasystick.WitchAuditTestSupport.drain(f,"championAuditDrain");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import java.nio.file.Path;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class ChampionQuestEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@ParameterizedTest @ValueSource(strings={"individual-only","party-available","party-required"})
|
||||
void registeredExplicitChampionModesAbandonmentAndRerollExportOnlyFrozenAcknowledgedSoloOrPersistentPartyAuthority(String mode)throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var leader=fixture.actor;var member=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
member.core.location.setWorld(leader.world);f.players.put(member.core.id,member.player);f.activate(leader);f.activate(member);
|
||||
var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(leader.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(member.core.id,parties.invite(leader.core.id,party.id(),member.core.id).orElseThrow().id()));
|
||||
f.adminCommand.execute(leader.player,"fstickadmin",new String[]{"adventure-site","place","champion","--mode",mode,"Same Champion"});
|
||||
ReskillEventsNativeTest.await(f,()->fixture.directory.snapshot().sites().size()==1);var site=fixture.directory.snapshot().sites().getFirst();
|
||||
fixture.giver();fixture.resetCatalog();var leaderBefore=CombatExperienceNativeTest.record(f,leader);var memberBefore=CombatExperienceNativeTest.record(f,member);
|
||||
boolean personal=mode.equals("individual-only");
|
||||
if(personal){fixture.click(leader,fixture.open(leader),true);assertTrue(fixture.directory.active().isEmpty());assertTrue(leader.messages.contains("Quest acceptance refused: this site is individual-only."));}
|
||||
else{fixture.click(member,fixture.open(member),true);assertTrue(fixture.directory.active().isEmpty());assertTrue(member.messages.contains("Only the active party leader may accept a party quest."));
|
||||
if(mode.equals("party-required")){fixture.click(leader,fixture.open(leader),false);assertTrue(fixture.directory.active().isEmpty());assertTrue(leader.messages.contains("Quest acceptance refused: this site is party-required."));}}
|
||||
var accepting=personal?member:leader;fixture.click(accepting,fixture.open(accepting),!personal);
|
||||
ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var original=fixture.directory.active().getFirst();
|
||||
assertEquals(mode,original.participationMode().value());assertEquals(personal?Set.of(memberBefore.id()):Set.of(leaderBefore.id(),memberBefore.id()),original.snapshot());
|
||||
assertEquals(personal?memberBefore.id():party.id(),original.unit());assertEquals(BigInteger.valueOf(150),original.rewardXp());assertEquals(BigInteger.valueOf(40),original.rewardCoins());
|
||||
String futureMode=personal?"party-required":"individual-only";
|
||||
f.adminCommand.execute(leader.player,"fstickadmin",new String[]{"adventure-site","mode",site.id().toString(),futureMode});
|
||||
ReskillEventsNativeTest.await(f,()->fixture.directory.findSite(site.id()).orElseThrow().participationMode().value().equals(futureMode));
|
||||
var settings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");
|
||||
f.adminCommand.execute(leader.player,"fstickadmin",new String[]{"config","adventure-champion-reward-xp","175"});
|
||||
f.adminCommand.execute(leader.player,"fstickadmin",new String[]{"config","adventure-champion-reward-coins","45"});
|
||||
ReskillEventsNativeTest.await(f,()->settings.value("adventure-champion-reward-xp").equals("175")&&settings.value("adventure-champion-reward-coins").equals("45"));
|
||||
assertEquals(original,fixture.directory.findQuest(original.id()).orElseThrow());
|
||||
fixture.click(accepting,fixture.open(accepting),true);ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty());
|
||||
f.adminCommand.execute(leader.player,"fstickadmin",new String[]{"adventure-site","mode",site.id().toString(),mode});
|
||||
ReskillEventsNativeTest.await(f,()->fixture.directory.findSite(site.id()).orElseThrow().participationMode().value().equals(mode));
|
||||
fixture.click(accepting,fixture.open(accepting),!personal);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);
|
||||
var successor=fixture.directory.active().getFirst();assertNotEquals(original.id(),successor.id());assertEquals(BigInteger.valueOf(175),successor.rewardXp());assertEquals(BigInteger.valueOf(45),successor.rewardCoins());
|
||||
if(!personal){
|
||||
var durable=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");
|
||||
assertEquals(games.dmg.fantasystick.party.CharacterPartyDirectory.Status.APPLIED,durable.transfer(leaderBefore.id(),party.id(),memberBefore.id()).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
|
||||
var added=new FantasyStickLifecycleNativeTest.Actor(data);added.core.location.setWorld(leader.world);f.players.put(added.core.id,added.player);f.activate(added);
|
||||
assertTrue(parties.accept(added.core.id,parties.invite(member.core.id,party.id(),added.core.id).orElseThrow().id()));assertFalse(successor.snapshot().contains(added.core.record.id()));
|
||||
assertEquals(successor,fixture.directory.findQuest(successor.id()).orElseThrow());
|
||||
}
|
||||
fixture.resetCatalog();assertTrue(fixture.directory.active().isEmpty());assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));
|
||||
ReskillEventsNativeTest.await(f,()->{
|
||||
try{return rows("adventure.quest.accepted").size()==2&&rows("adventure.quest.abandoned").size()==1&&rows("adventure.quest.expired").size()==1;}
|
||||
catch(Exception failure){throw new AssertionError(failure);}
|
||||
});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
|
||||
var accepted=rows("adventure.quest.accepted");var abandoned=rows("adventure.quest.abandoned");var expired=rows("adventure.quest.expired");
|
||||
assertEquals(4,accepted.size()+abandoned.size()+expired.size(),"Only the actual two saved champion acceptances, acknowledged abandonment and full-reroll expiry may export, not refused choices");
|
||||
assertEquals(2,accepted.size());assertEquals(1,abandoned.size());assertEquals(1,expired.size());
|
||||
for(var quest:java.util.List.of(original,successor)){
|
||||
var row=accepted.stream().filter(value->value.getAsJsonObject("data").get("quest_id").getAsString().equals(quest.id().toString())).findFirst().orElseThrow();var values=row.getAsJsonObject("data");
|
||||
assertEquals(accepting.core.id.toString(),row.get("playerid").getAsString());assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());assertEquals(site.id().toString(),values.get("site_id").getAsString());
|
||||
assertTrue(values.has("giver_id"),"Acknowledged champion acceptance must retain the actual original Quest Giver target, not infer it during replay");
|
||||
var giver=fixture.directory.snapshot().givers().getFirst();assertEquals(giver.id().toString(),values.get("giver_id").getAsString());assertEquals(giver.world().toString(),values.getAsJsonObject("giver_location").get("world_id").getAsString());
|
||||
assertEquals(mode,values.get("captured_mode").getAsString());assertEquals(personal?"solo":"party",values.get("unit_kind").getAsString());assertEquals(quest.unit().toString(),values.get("unit_id").getAsString());
|
||||
assertEquals(quest.rewardXp().toString(),values.get("reward_xp_pool").getAsString());assertEquals(quest.rewardCoins().toString(),values.get("reward_coin_pool").getAsString());assertEquals(quest.expiresAt().toString(),values.get("deadline").getAsString());
|
||||
if(!personal)assertEquals(leaderBefore.id().toString(),values.get("accepting_leader_character_id").getAsString());
|
||||
}
|
||||
assertEquals(original.id().toString(),abandoned.getFirst().getAsJsonObject("data").get("quest_id").getAsString());
|
||||
assertEquals("unavailable_not_in_saved_quest",abandoned.getFirst().getAsJsonObject("data").get("accepting_actor_capture_quality").getAsString());
|
||||
var ended=expired.getFirst().getAsJsonObject("data");assertEquals(successor.id().toString(),ended.get("quest_id").getAsString());assertEquals("full_day_reroll",ended.get("reason").getAsString());
|
||||
assertEquals(mode,ended.get("captured_mode").getAsString());assertEquals(successor.rewardXp().toString(),ended.get("reward_xp_pool").getAsString());
|
||||
assertEquals(successor.snapshot().stream().map(Object::toString).collect(java.util.stream.Collectors.toSet()),
|
||||
java.util.stream.StreamSupport.stream(ended.getAsJsonArray("accepted_roster").spliterator(),false).map(value->value.getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertTrue(rows("adventure.trophy_earned").isEmpty());assertTrue(rows("adventure.reward_claimed").isEmpty());
|
||||
}
|
||||
}
|
||||
private java.util.List<com.google.gson.JsonObject> rows(String type)throws Exception{
|
||||
return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)
|
||||
&&row.getAsJsonObject("data").has("site_kind")&&row.getAsJsonObject("data").get("site_kind").getAsString().equals("champion")).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ChampionSiteEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
|
||||
@Test void registeredSameNameChampionModeAndRemovalKeepExactRevisionsAndRejectUnchangedDeniedInvalidStaleAndFailedMutations()throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
|
||||
for(int index=0;index<2;index++){
|
||||
int wanted=index+1;assertTrue(f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","champion","Same Champion"}));
|
||||
ReskillEventsNativeTest.await(f,()->fixture.directory.snapshot().sites().size()==wanted);
|
||||
}
|
||||
var original=fixture.directory.snapshot().sites().getFirst();var other=fixture.directory.snapshot().sites().getLast();
|
||||
assertNotEquals(original.id(),other.id());assertNotEquals(original.registrationRevision(),other.registrationRevision());
|
||||
actor.allowed=false;f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",original.id().toString(),"party-required"});
|
||||
assertEquals(original,fixture.directory.findSite(original.id()).orElseThrow());actor.allowed=true;
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",original.id().toString(),"bad-mode"});
|
||||
assertEquals(original,fixture.directory.findSite(original.id()).orElseThrow());
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",original.id().toString(),"party-required"});
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Updated adventure site")));
|
||||
var changed=fixture.directory.findSite(original.id()).orElseThrow();assertNotNull(changed.registrationRevision());
|
||||
assertNotEquals(original.registrationRevision(),changed.registrationRevision());assertEquals(changed,new YamlAdventureQuestRepository(data).load().sites().stream().filter(value->value.id().equals(changed.id())).findFirst().orElseThrow());
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",changed.id().toString(),"party-required"});
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.contains("mode is already party-required")));
|
||||
assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.ModeStatus.STALE,fixture.directory.setSiteMode(changed.id(),
|
||||
original.participationMode(),AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
|
||||
var file=data.resolve("adventure-quests.yml");var bytes=java.nio.file.Files.readAllBytes(file);java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",changed.id().toString(),"individual-only"});
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.contains("Adventure site mode save failed; the acknowledged mode is unchanged."));
|
||||
assertEquals(changed,fixture.directory.findSite(changed.id()).orElseThrow());java.nio.file.Files.delete(file);java.nio.file.Files.write(file,bytes);
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","remove",other.id().toString()});
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Removed adventure site "+other.id())));
|
||||
assertEquals(changed,fixture.directory.findSite(changed.id()).orElseThrow());assertTrue(fixture.directory.findSite(other.id()).isEmpty());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
ReskillEventsNativeTest.await(f,()->{
|
||||
try{return championRows("adventure.site.registered").size()==2&&championRows("adventure.site.mode_changed").size()==1
|
||||
&&championRows("adventure.site.removed").size()==1;}catch(Exception failure){throw new AssertionError(failure);}
|
||||
});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
|
||||
var modes=championRows("adventure.site.mode_changed");var removals=championRows("adventure.site.removed");
|
||||
assertEquals(2,modes.size()+removals.size(),"Actual acknowledged champion mode/removal must export exactly their own mutations, never refused/failed copies");
|
||||
assertEquals(1,modes.size());assertEquals(1,removals.size());var mode=modes.getFirst().getAsJsonObject("data");var removed=removals.getFirst().getAsJsonObject("data");
|
||||
assertEquals(changed.id().toString(),mode.get("site_id").getAsString());assertEquals(changed.registrationRevision().toString(),mode.get("site_revision").getAsString());
|
||||
assertEquals(original.registrationRevision().toString(),mode.get("requested_site_revision").getAsString());assertEquals("party-available",mode.get("mode_before").getAsString());assertEquals("party-required",mode.get("mode_after").getAsString());
|
||||
assertEquals("future_acceptances",mode.get("application_scope").getAsString());assertEquals(other.id().toString(),removed.get("site_id").getAsString());assertEquals(other.registrationRevision().toString(),removed.get("site_revision").getAsString());
|
||||
assertEquals("retained",removed.get("earned_reward_impact").getAsString());assertEquals("Same Champion",removed.get("site_name").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
private java.util.List<com.google.gson.JsonObject> championRows(String type)throws Exception{
|
||||
return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)
|
||||
&&row.getAsJsonObject("data").get("site_kind").getAsString().equals("champion")).toList();
|
||||
}
|
||||
|
||||
@Test void registeredChampionCreationPersistsIndependentRevisionAndExportsExactOriginalActorDefinitionAfterReload()throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
|
||||
String originalSession=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions"))
|
||||
.active(actor.core.id).orElseThrow().session().toString();
|
||||
assertTrue(f.adminCommand.getExecutor().onCommand(actor.player,f.adminCommand,"fstickadmin",
|
||||
new String[]{"adventure-site","place","champion","--mode","individual-only","8","16","Same Champion"}));
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Placed champion")));
|
||||
var saved=fixture.directory.snapshot().sites().getFirst();
|
||||
assertEquals(AdventureQuestState.SiteType.CHAMPION,saved.type());assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,saved.participationMode());
|
||||
assertNotNull(saved.registrationRevision(),"Actual acknowledged champion creation must retain its independent definition revision");
|
||||
assertNotEquals(saved.id(),saved.registrationRevision());assertEquals(AdventureQuestState.Arena.rectangle(8,16),saved.arena());
|
||||
assertEquals(saved,new YamlAdventureQuestRepository(data).load().sites().getFirst());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
ReskillEventsNativeTest.await(f,()->{
|
||||
try{return ReskillEventsNativeTest.records(data).stream().anyMatch(row->row.get("type").getAsString().endsWith("adventure.site.registered")
|
||||
&&row.getAsJsonObject("data").get("site_kind").getAsString().equals("champion"));}
|
||||
catch(Exception failure){throw new AssertionError(failure);}
|
||||
});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
|
||||
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.site.registered")
|
||||
&&row.getAsJsonObject("data").get("site_kind").getAsString().equals("champion")).toList();
|
||||
assertEquals(1,rows.size(),"Actual saved registered champion administration must export one owner record, not depend on camp-only filtering");
|
||||
var row=rows.getFirst();var values=row.getAsJsonObject("data");
|
||||
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(before.id().toString(),row.get("characterid").getAsString());assertEquals(originalSession,values.get("session_id").getAsString());
|
||||
assertEquals(saved.id().toString(),values.get("site_id").getAsString());assertEquals(saved.registrationRevision().toString(),values.get("site_revision").getAsString());
|
||||
assertEquals("champion",values.get("site_kind").getAsString());assertEquals("persisted",values.get("revision_capture_quality").getAsString());
|
||||
assertEquals("individual-only",values.get("mode_after").getAsString());assertEquals("Same Champion",values.get("site_name").getAsString());
|
||||
assertEquals(saved.world().toString(),values.get("world_id").getAsString());assertEquals(12,values.get("x").getAsDouble());assertEquals(70,values.get("y").getAsDouble());assertEquals(-8,values.get("z").getAsDouble());
|
||||
assertEquals(8,values.getAsJsonObject("arena").get("x_blocks").getAsInt());assertEquals(16,values.getAsJsonObject("arena").get("z_blocks").getAsInt());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
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.entity.EntityRegainHealthEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class CharacterBossAbilityEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
static final class NativeHealingGuard implements Listener {
|
||||
final boolean cancel;final List<EntityRegainHealthEvent> observed=new ArrayList<>();
|
||||
NativeHealingGuard(boolean cancel){this.cancel=cancel;}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void regain(EntityRegainHealthEvent event){if(event.getEntity().getType()!=org.bukkit.entity.EntityType.VINDICATOR)return;event.setAmount(5);event.setCancelled(cancel);observed.add(event);}
|
||||
}
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void actualSixReadyNativeSlotsDamageAndAdjustedOrCancelledHealingReachSystemCastOwnersWithoutTemplateMutation(boolean cancelled)throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();shell.entity.setHealth(50);b.nativeEffects.get(shell.entity.getUniqueId()).put(PotionEffectType.WEAKNESS,new PotionEffect(PotionEffectType.WEAKNESS,200,0));var nativeDamage=new ArrayList<EntityDamageByEntityEvent>();b.actor.damage=(amount,sourceEntity)->{var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(sourceEntity);when(source.getDirectEntity()).thenReturn(sourceEntity);var event=new EntityDamageByEntityEvent(sourceEntity,b.actor.player,EntityDamageEvent.DamageCause.MAGIC,source,amount);event.setCancelled(cancelled);b.f.dispatcher.accept(event);nativeDamage.add(event);};var healing=new NativeHealingGuard(cancelled);b.f.dispatcher.register(healing,b.f.plugin);fixture.damage(b.actor,shell,3,false);b.shared.pulse();assertEquals(2,nativeDamage.size(),"Both ready offensive slots actually traverse native damage dispatch in the same pulse");assertEquals(1,healing.observed.size());assertEquals(cancelled?50:55,shell.health[0],"Actual native adjusted/cancelled healing governs applied shell health, not requested ability amount");assertFalse(b.nativeEffects.get(shell.entity.getUniqueId()).containsKey(PotionEffectType.WEAKNESS));assertTrue(b.nativeEffects.get(shell.entity.getUniqueId()).containsKey(PotionEffectType.RESISTANCE));assertTrue(b.playerEffects.containsKey(PotionEffectType.WEAKNESS));assertTrue(b.playerEffects.containsKey(PotionEffectType.SLOWNESS));assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertEquals(fixture.template,((games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characters")).find(fixture.template.id()).get().orElseThrow());settle(b);
|
||||
var casts=owned("ability.cast");assertEquals(6,casts.size(),"Six actual ready slot attempts, including native rejected healing, need original independent system cast facts; ally-less Guardian Bond cannot be fabricated");assertEquals(Set.of("fireball","frost_nova","hex","second_wind","purify","stone_skin"),casts.stream().map(row->row.getAsJsonObject("data").get("ability_id").getAsString()).collect(java.util.stream.Collectors.toSet()));var resolved=owned("ability.resolved");assertEquals(6,resolved.size());var damage=owned("combat.damage");assertEquals(2,damage.size());for(int i=0;i<2;i++){var nativeEvent=nativeDamage.get(i);var abilityId=i==0?"fireball":"frost_nova";var matching=damage.stream().filter(row->row.getAsJsonObject("data").get("ability_id").getAsString().equals(abilityId)).findFirst().orElseThrow();var values=matching.getAsJsonObject("data");assertEquals(nativeEvent.isCancelled()?"cancelled":nativeEvent.getFinalDamage()>0?"accepted":"no_op",values.get("outcome").getAsString());assertEquals(nativeEvent.getFinalDamage(),values.get("final_damage").getAsDouble());assertEquals(b.actor.core.id.toString(),values.get("target_player_id").getAsString());assertEquals(b.actor.core.record.id().toString(),values.get("target_character_id").getAsString());}var healed=owned("combat.healed");assertEquals(1,healed.size());assertEquals(cancelled?"cancelled":"accepted",healed.getFirst().getAsJsonObject("data").get("outcome").getAsString());assertEquals(cancelled?0:5,healed.getFirst().getAsJsonObject("data").get("applied_amount").getAsDouble());assertEquals(shell.entity.getUniqueId().toString(),healed.getFirst().getAsJsonObject("data").get("target_entity_id").getAsString());for(var row:casts){var values=row.getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(shell.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals(fixture.custody.operation().toString(),values.get("custody_operation_id").getAsString());assertFalse(values.has("session_id"));assertFalse(row.has("playerid"));assertTrue(resolved.stream().anyMatch(result->result.getAsJsonObject("data").get("cast_id").equals(values.get("cast_id"))));}int attacks=nativeDamage.size();b.shared.pulse();assertEquals(attacks,nativeDamage.size(),"Existing native boss-only offensive cooldowns stay authoritative even when damage was cancelled");
|
||||
}
|
||||
}
|
||||
private List<com.google.gson.JsonObject> owned(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)&&row.getAsJsonObject("data").has("entity_role")&&row.getAsJsonObject("data").get("entity_role").getAsString().equals("character_boss")).toList();}
|
||||
private static void settle(DungeonAuditNativeFixture b)throws Exception{fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));}
|
||||
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Mob;
|
||||
|
||||
/** Feature-local extension of strict dungeon ports; real template custody, menus and registered commands remain installed. */
|
||||
final class CharacterBossAuditNativeFixture implements AutoCloseable {
|
||||
final DungeonAuditNativeFixture base;
|
||||
final FantasyStickLifecycleNativeTest.Actor templateActor;
|
||||
final CharacterRecord template;
|
||||
final Map<UUID,NativeBoss> shells=new HashMap<>();
|
||||
games.dmg.fantasystick.dungeon.DungeonState.CharacterBoss custody;
|
||||
static final class NativeBoss {
|
||||
final Mob entity;final double[] maximum={20},attack={1},health={20},absorption={0};final LivingEntity[] target={null};
|
||||
NativeBoss(Mob entity){this.entity=entity;}
|
||||
}
|
||||
CharacterBossAuditNativeFixture(Path data)throws Exception{this(data,List.of("speed_i","low_light_vision","quicker_regeneration_i"));}
|
||||
CharacterBossAuditNativeFixture(Path data,List<String> equippedPassives)throws Exception{this(data,equippedPassives,Map.of());}
|
||||
CharacterBossAuditNativeFixture(Path data,List<String> equippedPassives,Map<Stat,BigInteger> configuredStats)throws Exception{
|
||||
assertEquals(3,equippedPassives.size(),"Real template has exactly three passive slots");
|
||||
base=new DungeonAuditNativeFixture(data);templateActor=new FantasyStickLifecycleNativeTest.Actor(data);base.f.players.put(templateActor.core.id,templateActor.player);var old=templateActor.core.record;var stats=new java.util.EnumMap<Stat,BigInteger>(Stat.class);for(var stat:Stat.values())stats.put(stat,configuredStats.getOrDefault(stat,BigInteger.valueOf(30)));var abilities=List.of("fireball","frost_nova","hex","second_wind","purify","stone_skin","guardian_bond");
|
||||
template=new CharacterRecord(old.id(),"Original Template Boss",old.revision().add(BigInteger.ONE),stats,old.level(),old.experience(),old.unspentPoints(),BigDecimal.valueOf(40),Set.copyOf(abilities),abilities,List.copyOf(equippedPassives),Map.of("fireball",java.time.Instant.parse("2099-01-01T00:00:00Z")),old.restrictedKills(),false,null,null,old.coins(),old.coinsInitialized(),old.rewardReceipts(),old.pvpCoinEscrow(),new games.dmg.fantasystick.persistence.CharacterGear(games.dmg.fantasystick.persistence.GearTier.COPPER,games.dmg.fantasystick.persistence.GearTier.GOLD,games.dmg.fantasystick.persistence.GearTier.IRON),old.sheetGeneration());new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(template,old.revision());templateActor.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(template));var originalPort=base.shared.nativeSpawnSetup;base.shared.nativeSpawnSetup=entity->{originalPort.accept(entity);if(entity.getType()==EntityType.VINDICATOR)configure((Mob)entity);};
|
||||
}
|
||||
private void configure(Mob entity){
|
||||
var state=new NativeBoss(entity);shells.put(entity.getUniqueId(),state);var maximum=mock(org.bukkit.attribute.AttributeInstance.class);var attack=mock(org.bukkit.attribute.AttributeInstance.class);when(entity.getAttribute(Attribute.MAX_HEALTH)).thenReturn(maximum);when(entity.getAttribute(Attribute.ATTACK_DAMAGE)).thenReturn(attack);when(maximum.getValue()).thenAnswer(call->state.maximum[0]);when(attack.getValue()).thenAnswer(call->state.attack[0]);doAnswer(call->{state.maximum[0]=call.getArgument(0);return null;}).when(maximum).setBaseValue(anyDouble());doAnswer(call->{state.attack[0]=call.getArgument(0);return null;}).when(attack).setBaseValue(anyDouble());when(entity.getHealth()).thenAnswer(call->state.health[0]);doAnswer(call->{state.health[0]=call.getArgument(0);return null;}).when(entity).setHealth(anyDouble());when(entity.getEquipment()).thenReturn(mock(org.bukkit.inventory.EntityEquipment.class));when(entity.hasLineOfSight(any(Entity.class))).thenReturn(true);when(entity.getAbsorptionAmount()).thenAnswer(call->state.absorption[0]);doAnswer(call->{state.absorption[0]=call.getArgument(0);return null;}).when(entity).setAbsorptionAmount(anyDouble());doAnswer(call->{state.target[0]=call.getArgument(0);return null;}).when(entity).setTarget(any());when(entity.getTarget()).thenAnswer(call->state.target[0]);
|
||||
}
|
||||
void authorAndAccept()throws Exception{
|
||||
base.authorAndAccept("players",true,()->{var held=templateActor.core.inventory.getItemInOffHand();templateActor.core.inventory.setItemInOffHand(null);base.actor.core.inventory.setItemInMainHand(held);base.command("dungeon","room","boss","assign-sheet","2");custody=base.directory.custody(template.id()).orElseThrow();assertEquals(template,custody.character());assertTrue(base.actor.core.inventory.getItemInMainHand().isEmpty());});
|
||||
assertNotEquals(template.id(),base.actor.core.record.id());assertEquals(Set.of(base.actor.core.record.id()),base.quest.snapshot());assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(base.f.plugin,"sessions")).active(templateActor.core.id).isEmpty());
|
||||
}
|
||||
FantasyStickLifecycleNativeTest.Actor acceptedChallenger(Path data)throws Exception{
|
||||
var challenger=new FantasyStickLifecycleNativeTest.Actor(data);base.f.players.put(challenger.core.id,challenger.player);challenger.core.location.setWorld(base.actor.world);challenger.core.location.setX(12.75);challenger.core.location.setY(base.actor.core.location.getY());challenger.core.location.setZ(base.actor.core.location.getZ());doReturn(base.actor.world).when(challenger.player).getWorld();doReturn(false).when(challenger.player).isDead();DungeonAuditNativeFixture.effects(challenger.player,new HashMap<>());challenger.damage=(amount,source)->{};base.f.activate(challenger);base.shared.click(challenger,base.shared.open(challenger),false);ReskillEventsNativeTest.await(base.f,()->base.shared.directory.active().stream().anyMatch(quest->quest.snapshot().contains(challenger.core.record.id())));challenger.core.location.setX(17.75);return challenger;
|
||||
}
|
||||
void damage(FantasyStickLifecycleNativeTest.Actor actor,NativeBoss shell,double amount,boolean cancelled){
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var event=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,shell.entity,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,amount);event.setCancelled(cancelled);base.f.dispatcher.accept(event);assertEquals(cancelled,event.isCancelled());
|
||||
}
|
||||
NativeBoss shell(){var entity=base.nativeEntity(EntityType.VINDICATOR);return shells.get(entity.getUniqueId());}
|
||||
@Override public void close()throws java.io.IOException{base.close();try{WitchAuditTestSupport.drain(base.f,"characterBossDrain");}catch(Exception failure){throw new java.io.IOException("Character boss audit fixture did not drain",failure);}}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CharacterBossCustodyEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void authenticHeldInactivePartylessCharacterReachesRegisteredAssignmentConsumptionAndExactRecoveryBeforeIndependentOwnerSuccess()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();var originalLocation=actor.core.location.clone();
|
||||
command(f,actor,"dungeon","create","--mode","individual-only","Original Character Realm");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Custody Room");var room=source.find(dungeon.id()).orElseThrow().rooms().getFirst();command(f,actor,"dungeon","room","boss","mob","husk","Original Prior Guard");var prior=source.room(dungeon.id(),room.id()).orElseThrow().boss();var sheets=new CharacterSheets();var held=actor.core.inventory.getItemInOffHand();assertTrue(sheets.authorizes(held,saved));actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);var unrelated=java.util.Arrays.stream(actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);
|
||||
command(f,actor,"dungeon","room","boss","assign-sheet","1.125");var custody=source.custody(saved.id()).orElseThrow();assertEquals(saved,custody.character());assertEquals("1.125",custody.multiplier().toPlainString());assertEquals(prior.type(),custody.priorType());assertEquals(prior.name(),custody.priorName());assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());for(int slot=1;slot<unrelated.length;slot++)assertEquals(unrelated[slot],actor.core.inventory.getItem(slot));assertEquals(saved,storage.find(saved.id()).get().orElseThrow());assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).isEmpty());
|
||||
command(f,actor,"dungeon","edit","done");actor.core.location.setX(100);command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());assertTrue(source.custody(saved.id()).isEmpty());assertNull(source.room(dungeon.id(),room.id()).orElseThrow().boss());assertFalse(source.find(dungeon.id()).orElseThrow().enabled());var recovered=java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,saved)).toList();assertEquals(1,recovered.size());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());var recoveryLocation=actor.core.location.clone();ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.character_boss.assigned").size()==1&&rows("dungeon.character_boss.recovered").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
var assigned=rows("dungeon.character_boss.assigned");assertEquals(1,assigned.size(),"Actual saved custody plus exact physical consumption needs its independent success owner, not generic admin.action or definition configuration");var values=assigned.getFirst().getAsJsonObject("data");assertEquals(actor.core.id.toString(),assigned.getFirst().get("playerid").getAsString());assertFalse(values.has("session_id"));assertEquals(saved.id().toString(),values.get("subject_character_id").getAsString());assertEquals(saved.name(),values.get("subject_character_name").getAsString());assertEquals(saved.revision().toString(),values.get("subject_character_revision").getAsString());assertEquals(saved.sheetGeneration().toString(),values.get("subject_sheet_generation").getAsString());assertEquals(custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals(dungeon.id().toString(),values.get("dungeon_id").getAsString());assertEquals(room.id().toString(),values.get("room_id").getAsString());assertEquals("1.125",values.get("multiplier").getAsString());assertEquals(originalLocation.getX(),values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals("HUSK",values.get("prior_entity_type").getAsString());assertEquals("Original Prior Guard",values.get("prior_entity_name").getAsString());assertFalse(values.has("character_snapshot"));assertFalse(values.has("item"));
|
||||
var returns=rows("dungeon.character_boss.recovered");assertEquals(1,returns.size());var returned=returns.getFirst().getAsJsonObject("data");assertEquals(saved.id().toString(),returned.get("subject_character_id").getAsString());assertEquals(custody.operation().toString(),returned.get("custody_operation_id").getAsString());assertTrue(returned.has("recovery_operation_id"));assertEquals(recoveryLocation.getX(),returned.getAsJsonObject("actor_location").get("x").getAsDouble());assertTrue(rows("combat.reward_resolved").isEmpty());assertTrue(rows("dungeon.completed").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
|
||||
void acknowledgedOriginalCustodyWithoutDeferredConsumptionRemainsPendingNotSuccessAndExactOriginalSheetRecovers(boolean offline)throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();command(f,actor,"dungeon","create","Original Pending Custody");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Pending Room");var room=source.find(dungeon.id()).orElseThrow().rooms().getFirst();command(f,actor,"dungeon","room","boss","mob","husk","Original Pending Guard");var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);var originalInventory=java.util.Arrays.stream(actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);var originalLocation=actor.core.location.clone();
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"dungeon","room","boss","assign-sheet","1.125"});if(offline)org.mockito.Mockito.doReturn(false).when(actor.player).isOnline();else org.mockito.Mockito.doReturn(false).when(actor.player).hasPermission("fantasystick.admin");actor.core.location.setX(100);ReskillEventsNativeTest.await(f,()->source.custody(saved.id()).isPresent());settleSourcesAndAudit(f,source,storage);var custody=source.custody(saved.id()).orElseThrow();assertEquals(saved,custody.character());assertArrayEquals(originalInventory,actor.core.inventory.getContents());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());assertTrue(rows("dungeon.character_boss.assigned").isEmpty(),"Saved source custody alone cannot prove physical sheet consumption");
|
||||
var pending=rows("action.resolved").stream().filter(row->row.getAsJsonObject("data").has("custody_operation_id")).toList();assertEquals(1,pending.size(),"An acknowledged source custody phase must remain observable without deferred reply permission/online status, explicitly pending rather than assigned");var values=pending.getFirst().getAsJsonObject("data");assertEquals("pending_native_consumption",values.get("outcome").getAsString());assertEquals("custody_saved",values.get("phase").getAsString());assertEquals(custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals(saved.id().toString(),values.get("subject_character_id").getAsString());assertEquals(actor.core.id.toString(),pending.getFirst().get("playerid").getAsString());assertEquals(originalLocation.getX(),values.getAsJsonObject("actor_location").get("x").getAsDouble());assertFalse(values.has("session_id"));
|
||||
org.mockito.Mockito.doReturn(true).when(actor.player).hasPermission("fantasystick.admin");org.mockito.Mockito.doReturn(true).when(actor.player).isOnline();command(f,actor,"dungeon","edit","done");command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.character_boss.recovered").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});assertTrue(source.custody(saved.id()).isEmpty());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());var sheets=new CharacterSheets();assertEquals(1,java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,saved)).count());f.plugin.setEnabled(false);f.awaitWorkers();assertEquals(1,rows("dungeon.character_boss.recovered").size());assertTrue(rows("dungeon.character_boss.assigned").isEmpty());assertTrue(rows("combat.reward_resolved").isEmpty());
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
|
||||
void deliveredOperationMarkedSheetWithFailedFinalCustodySaveRemainsPendingAndRetryCompletesExactRecoveryOnce(boolean restart)throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();command(f,actor,"dungeon","create","Original Interrupted Recovery");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Recovery Room");var room=source.find(dungeon.id()).orElseThrow().rooms().getFirst();command(f,actor,"dungeon","room","boss","mob","husk","Original Recovery Guard");var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);command(f,actor,"dungeon","room","boss","assign-sheet","1.125");var custody=source.custody(saved.id()).orElseThrow();assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());command(f,actor,"dungeon","edit","done");actor.core.location.setX(100);var originalLocation=actor.core.location.clone();var inventory=org.mockito.Mockito.spy(actor.core.inventory);org.mockito.Mockito.when(actor.player.getInventory()).thenReturn(inventory);var blocked=new java.util.concurrent.atomic.AtomicBoolean();var target=data.resolve("dungeons.yml");var backup=data.resolve("last-prepared-dungeons.yml");org.mockito.Mockito.doAnswer(call->{call.callRealMethod();if(blocked.compareAndSet(false,true)){Files.move(target,backup);Files.createDirectory(target);Files.writeString(target.resolve("blocked"),"test-only final source save refusal");}return null;}).when(inventory).setItem(org.mockito.ArgumentMatchers.eq(0),org.mockito.ArgumentMatchers.any(org.bukkit.inventory.ItemStack.class));
|
||||
command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());assertTrue(blocked.get());assertTrue(actor.messages.getLast().contains("durable custody completion must be retried"),actor.messages.toString());var prepared=source.custody(saved.id()).orElseThrow();assertNotNull(prepared.recovery());assertEquals(custody.operation(),prepared.operation());assertEquals(saved,prepared.character());var sheets=new CharacterSheets();var delivered=java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,saved)).toList();assertEquals(1,delivered.size());assertTrue(sheets.hasRecovery(delivered.getFirst(),prepared.recovery().operation()));assertEquals(saved,storage.find(saved.id()).get().orElseThrow());assertTrue(rows("dungeon.character_boss.recovered").isEmpty(),"Native sheet delivery cannot acknowledge failed durable custody completion");settleSourcesAndAudit(f,source,storage);
|
||||
var pending=rows("action.resolved").stream().filter(row->"recovery_completion_pending".equals(row.getAsJsonObject("data").has("phase")?row.getAsJsonObject("data").get("phase").getAsString():null)).toList();assertEquals(1,pending.size(),"Exact native recovery delivery with failed final source save must retain original pending evidence, not success or silence");var values=pending.getFirst().getAsJsonObject("data");assertEquals("pending_durable_completion",values.get("outcome").getAsString());assertEquals(prepared.recovery().operation().toString(),values.get("recovery_operation_id").getAsString());assertEquals(custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals(saved.id().toString(),values.get("subject_character_id").getAsString());assertEquals("exact_authorized_sheet_delivered",values.get("sheet_status").getAsString());assertEquals(originalLocation.getX(),values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(actor.core.id.toString(),pending.getFirst().get("playerid").getAsString());
|
||||
Files.delete(target.resolve("blocked"));Files.delete(target);Files.move(backup,target);if(restart){var persisted=Files.readAllBytes(target);var characterFile=data.resolve("characters").resolve(saved.id()+".yml");var characterBytes=Files.readAllBytes(characterFile);f.plugin.setEnabled(false);f.awaitWorkers();WitchAuditTestSupport.drain(f,"characterBossDrain");f.plugin.setEnabled(true);source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(prepared,source.custody(saved.id()).orElseThrow(),"Restart preserves exact pending custody, original template and recovery operation rather than deriving completion from physical delivery");assertEquals(saved,storage.find(saved.id()).get().orElseThrow());assertArrayEquals(persisted,Files.readAllBytes(target));assertArrayEquals(characterBytes,Files.readAllBytes(characterFile));assertTrue(sheets.hasRecovery(actor.core.inventory.getItem(0),prepared.recovery().operation()));assertTrue(rows("dungeon.character_boss.recovered").isEmpty());}actor.core.location.setX(105);command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.character_boss.recovered").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});assertTrue(source.custody(saved.id()).isEmpty());assertNull(source.room(dungeon.id(),room.id()).orElseThrow().boss());assertFalse(source.find(dungeon.id()).orElseThrow().enabled());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());var returned=java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,saved)).toList();assertEquals(1,returned.size());assertFalse(sheets.hasRecovery(returned.getFirst(),prepared.recovery().operation()));f.plugin.setEnabled(false);f.awaitWorkers();var returns=rows("dungeon.character_boss.recovered");assertEquals(1,returns.size());var returnedFacts=returns.getFirst().getAsJsonObject("data");assertEquals(prepared.recovery().operation().toString(),returnedFacts.get("recovery_operation_id").getAsString());assertEquals(105,returnedFacts.getAsJsonObject("actor_location").get("x").getAsDouble());assertTrue(rows("combat.reward_resolved").isEmpty());assertTrue(rows("dungeon.completed").isEmpty());
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
|
||||
void changedHeldSheetAfterSavedCustodyHasOriginalAcknowledgedRollbackOrPendingFailureEvidenceWithoutSuccess(boolean failedRollback)throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();command(f,actor,"dungeon","create","Original Rollback Custody");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Rollback Room");var room=source.find(dungeon.id()).orElseThrow().rooms().getFirst();command(f,actor,"dungeon","room","boss","mob","husk","Original Rollback Guard");var prior=source.room(dungeon.id(),room.id()).orElseThrow().boss();var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);var original=actor.core.location.clone();var target=data.resolve("dungeons.yml");var backup=data.resolve("last-assigned-dungeons.yml");var interrupted=new java.util.concurrent.atomic.AtomicBoolean();if(failedRollback){var inventory=org.mockito.Mockito.spy(actor.core.inventory);org.mockito.Mockito.when(actor.player.getInventory()).thenReturn(inventory);org.mockito.Mockito.doAnswer(call->{var item=call.callRealMethod();if(source.custody(saved.id()).isPresent()&&interrupted.compareAndSet(false,true)){Files.move(target,backup);Files.createDirectory(target);Files.writeString(target.resolve("blocked"),"test-only rollback source save refusal");}return item;}).when(inventory).getItemInMainHand();}
|
||||
var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(source,"worker");var entered=new java.util.concurrent.CountDownLatch(1);var release=new java.util.concurrent.CountDownLatch(1);worker.execute(()->{entered.countDown();try{if(!release.await(5,java.util.concurrent.TimeUnit.SECONDS))throw new AssertionError("Unreleased custody authority barrier");}catch(InterruptedException interruptedException){Thread.currentThread().interrupt();throw new AssertionError(interruptedException);}});assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));int messages=actor.messages.size();org.bukkit.inventory.ItemStack[] moved;try{f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"dungeon","room","boss","assign-sheet","1.125"});actor.core.inventory.setItemInMainHand(null);actor.core.inventory.setItem(5,held);actor.core.location.setX(100);moved=java.util.Arrays.stream(actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);}finally{release.countDown();}ReskillEventsNativeTest.await(f,()->actor.messages.size()>messages);assertArrayEquals(moved,actor.core.inventory.getContents());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());var expectedPhase=failedRollback?"assignment_rollback_pending":"assignment_cancelled";if(failedRollback){assertTrue(interrupted.get());assertTrue(actor.messages.getLast().contains("saved but sheet consumption could not be confirmed"),actor.messages.toString());assertEquals(saved,source.custody(saved.id()).orElseThrow().character());}else{assertTrue(actor.messages.getLast().contains("cancelled because the held sheet changed"),actor.messages.toString());assertTrue(source.custody(saved.id()).isEmpty());assertEquals(prior,source.room(dungeon.id(),room.id()).orElseThrow().boss());}settleSourcesAndAudit(f,source,storage);assertTrue(rows("dungeon.character_boss.assigned").isEmpty());var resolved=rows("action.resolved").stream().filter(row->expectedPhase.equals(row.getAsJsonObject("data").has("phase")?row.getAsJsonObject("data").get("phase").getAsString():null)).toList();assertEquals(1,resolved.size(),"Original acknowledged assignment rollback or unacknowledged rollback must reach its pending/cancelled owner, never assigned success or reconstructed current custody");var values=resolved.getFirst().getAsJsonObject("data");assertEquals(failedRollback?"pending_rollback":"cancelled",values.get("outcome").getAsString());assertEquals(saved.id().toString(),values.get("subject_character_id").getAsString());assertEquals(original.getX(),values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(actor.core.id.toString(),resolved.getFirst().get("playerid").getAsString());assertEquals("held_sheet_changed",values.get("reason").getAsString());assertTrue(values.has("custody_operation_id"));
|
||||
if(failedRollback){Files.delete(target.resolve("blocked"));Files.delete(target);Files.move(backup,target);}command(f,actor,"dungeon","edit","done");if(failedRollback){command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.character_boss.recovered").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});assertTrue(source.custody(saved.id()).isEmpty());}var sheets=new CharacterSheets();assertEquals(1,java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,saved)).count());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());f.plugin.setEnabled(false);f.awaitWorkers();assertTrue(rows("dungeon.character_boss.assigned").isEmpty());assertTrue(rows("combat.reward_resolved").isEmpty());assertTrue(rows("dungeon.completed").isEmpty());
|
||||
}
|
||||
}
|
||||
/** Test-local serialized fences; no source mutation, synthetic event or production lifecycle change. */
|
||||
private static void settleSourcesAndAudit(FantasyStickLifecycleNativeTest.Fixture f,DungeonDirectory source,CharacterStorage storage)throws Exception{
|
||||
fence(source,"worker");fence(storage,"worker");Runnable task;while((task=f.tasks.poll())!=null)task.run();
|
||||
var audit=FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterBossEvents");fence(FantasyStickLifecycleNativeTest.Fixture.field(audit,"outbox"),"worker");fence(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog"),"worker");
|
||||
}
|
||||
private static void fence(Object owner,String field)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,field)).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String...args)throws Exception{DungeonAuthoringEventsNativeTest.command(f,actor,args);}
|
||||
private List<com.google.gson.JsonObject> rows(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.events.CharacterBossEventBridge;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CharacterBossCustodyReplayNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void rejectedWriterCapturesExactRegisteredCustodyPhasesThenReplaysTwiceOfflineWithoutTemplateInventoryActorOrSourceRecreation()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");var folder=data.resolve("character-boss-event-intents");java.util.List<Properties> originals;byte[] definitions,character;Path characterFile;games.dmg.fantasystick.dungeon.DungeonState originalState;games.dmg.fantasystick.persistence.CharacterRecord originalCharacter;
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");originalCharacter=storage.find(actor.core.record.id()).get().orElseThrow();characterFile=data.resolve("characters").resolve(originalCharacter.id()+".yml");command(f,actor,"dungeon","create","Original Offline Custody");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Offline Room");command(f,actor,"dungeon","room","boss","mob","husk","Original Offline Guard");var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();command(f,actor,"dungeon","room","boss","assign-sheet","1.125");assertEquals(originalCharacter,source.custody(originalCharacter.id()).orElseThrow().character());assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());command(f,actor,"dungeon","edit","done");actor.core.location.setX(100);command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());assertTrue(source.custody(originalCharacter.id()).isEmpty());assertEquals(originalCharacter,storage.find(originalCharacter.id()).get().orElseThrow());f.plugin.setEnabled(false);f.awaitWorkers();WitchAuditTestSupport.drain(f,"characterBossDrain");originals=WitchAuditTestSupport.intents(folder);assertEquals(Map.of("action.resolved",1L,"dungeon.character_boss.assigned",1L,"dungeon.character_boss.recovered",1L),originals.stream().collect(java.util.stream.Collectors.groupingBy(value->value.getProperty("type"),java.util.stream.Collectors.counting())),"Rejected writer retains each original independent custody acknowledgement, not inferred success or gameplay recovery");originalState=new games.dmg.fantasystick.persistence.YamlDungeonRepository(data).load();definitions=Files.readAllBytes(data.resolve("dungeons.yml"));character=Files.readAllBytes(characterFile);
|
||||
}
|
||||
try(var reopened=new CampAuditNativeFixture(data)){
|
||||
var f=reopened.f;var actor=reopened.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).isEmpty());var inventory=java.util.Arrays.stream(actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);int messages=actor.messages.size();ReskillEventsNativeTest.await(f,()->{try{var rows=ReskillEventsNativeTest.records(data);return originals.stream().allMatch(intent->rows.stream().anyMatch(row->row.get("id").getAsString().equals(intent.getProperty("id"))));}catch(Exception failure){throw new AssertionError(failure);}});var audit=(CharacterBossEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterBossEvents");audit.replay();audit.replay();assertEquals(originalCharacter,storage.find(originalCharacter.id()).get().orElseThrow());f.plugin.setEnabled(false);f.awaitWorkers();WitchAuditTestSupport.drain(f,"characterBossDrain");var rows=ReskillEventsNativeTest.records(data);for(var intent:originals){var matching=rows.stream().filter(row->row.get("id").getAsString().equals(intent.getProperty("id"))).toList();assertEquals(1,matching.size());WitchAuditTestSupport.assertOriginal(intent,matching.getFirst());assertFalse(matching.getFirst().getAsJsonObject("data").has("session_id"));}assertEquals(originalState,source.snapshot());assertArrayEquals(definitions,Files.readAllBytes(data.resolve("dungeons.yml")));assertArrayEquals(character,Files.readAllBytes(characterFile));assertArrayEquals(inventory,actor.core.inventory.getContents());assertEquals(messages,actor.messages.size());assertTrue(reopened.spawned.isEmpty());assertTrue(source.custody(originalCharacter.id()).isEmpty());assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")||row.get("type").getAsString().endsWith("adventure.trophy_earned")));
|
||||
}
|
||||
}
|
||||
private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String...args)throws Exception{DungeonAuthoringEventsNativeTest.command(f,actor,args);}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class CharacterBossCustodySafetyNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@ParameterizedTest @ValueSource(strings={"permission","multiplier","duplicate","party","active","dead","stale","source_save","audit_storage"})
|
||||
void actualRegisteredRefusalOrUnavailableObservationCannotManufactureCustodySuccessOrChangeGameplay(String mode)throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();command(f,actor,"dungeon","create","Original Safety Custody");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Safety Room");command(f,actor,"dungeon","room","boss","mob","husk","Original Safety Guard");var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);var target=data.resolve("dungeons.yml");var backup=data.resolve("before-refused-dungeons.yml");var definitions=Files.readAllBytes(target);
|
||||
switch(mode){
|
||||
case "permission"->org.mockito.Mockito.doReturn(false).when(actor.player).hasPermission("fantasystick.admin");
|
||||
case "duplicate"->actor.core.inventory.setItem(5,held.clone());
|
||||
case "party"->((games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties")).create(saved.id(),"Original Refused Party").get();
|
||||
case "active"->{actor.core.inventory.setItemInMainHand(null);actor.core.inventory.setItemInOffHand(held);f.activate(actor);assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).isPresent());actor.core.inventory.setItemInMainHand(held);}
|
||||
case "dead","stale"->{var changed=changed(saved,mode.equals("dead"));new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(changed,saved.revision());}
|
||||
case "source_save"->{Files.move(target,backup);Files.createDirectory(target);Files.writeString(target.resolve("blocked"),"test-only initial source save refusal");}
|
||||
case "audit_storage"->Files.writeString(data.resolve("character-boss-event-intents"),"test-only unavailable audit storage");
|
||||
default->{ }
|
||||
}
|
||||
var before=storage.find(saved.id()).get().orElseThrow();var snapshot=source.snapshot();var inventory=java.util.Arrays.stream(actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);command(f,actor,"dungeon","room","boss","assign-sheet",mode.equals("multiplier")?"bogus":"1.125");
|
||||
if(mode.equals("audit_storage")){
|
||||
assertEquals(before,source.custody(saved.id()).orElseThrow().character());assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());assertEquals(before,storage.find(saved.id()).get().orElseThrow());command(f,actor,"dungeon","edit","done");actor.core.location.setX(100);command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());assertTrue(source.custody(saved.id()).isEmpty());var sheets=new games.dmg.fantasystick.items.CharacterSheets();assertEquals(1,java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,before)).count());assertEquals("test-only unavailable audit storage",Files.readString(data.resolve("character-boss-event-intents")));
|
||||
}else{
|
||||
assertTrue(source.custody(saved.id()).isEmpty());assertEquals(snapshot,source.snapshot());assertArrayEquals(inventory,actor.core.inventory.getContents());assertEquals(before,storage.find(saved.id()).get().orElseThrow());if(mode.equals("source_save")){Files.delete(target.resolve("blocked"));Files.delete(target);Files.move(backup,target);}assertArrayEquals(definitions,Files.readAllBytes(target));
|
||||
}
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();WitchAuditTestSupport.drain(f,"characterBossDrain");var rows=ReskillEventsNativeTest.records(data);assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("dungeon.character_boss.assigned")||row.get("type").getAsString().endsWith("dungeon.character_boss.recovered")||row.getAsJsonObject("data").has("custody_operation_id")),"Refused/unobserved transitions cannot create an owner success or reconstruct unavailable metadata");assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")));
|
||||
}
|
||||
}
|
||||
private static CharacterRecord changed(CharacterRecord old,boolean dead){return new CharacterRecord(old.id(),old.name(),old.revision().add(java.math.BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),dead?java.math.BigDecimal.ZERO:old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),dead,dead?java.util.UUID.randomUUID():old.deathId(),dead?java.time.Instant.now():old.diedAt(),old.coins(),old.coinsInitialized(),old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),dead?old.sheetGeneration():old.sheetGeneration().add(java.math.BigInteger.ONE));}
|
||||
private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String...args)throws Exception{DungeonAuthoringEventsNativeTest.command(f,actor,args);}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class CharacterBossEffectEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void confirmedEquippedPassivesAndRealNativePotionAcceptanceCancellationExpirationAndShellCleanupKeepIndependentOriginalApplications(boolean cancelled)throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var shellEffects=b.nativeEffects.get(shell.entity.getUniqueId());assertTrue(shellEffects.containsKey(PotionEffectType.SPEED));assertTrue(shellEffects.containsKey(PotionEffectType.NIGHT_VISION));var unrelated=new PotionEffect(PotionEffectType.SATURATION,400,2);b.playerEffects.put(PotionEffectType.SATURATION,unrelated);var nativeEvents=new ArrayList<EntityPotionEffectEvent>();nativePotionPort(b,shell.entity,shellEffects,shell.entity,cancelled,nativeEvents,Set.of(PotionEffectType.RESISTANCE));nativePotionPort(b,b.actor.player,b.playerEffects,shell.entity,cancelled,nativeEvents,Set.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS));b.actor.damage=(amount,source)->{};fixture.damage(b.actor,shell,3,false);b.shared.pulse();assertEquals(3,nativeEvents.size());assertTrue(nativeEvents.stream().allMatch(event->event.isCancelled()==cancelled));assertEquals(!cancelled,shellEffects.containsKey(PotionEffectType.RESISTANCE));assertEquals(!cancelled,b.playerEffects.containsKey(PotionEffectType.WEAKNESS));assertEquals(!cancelled,b.playerEffects.containsKey(PotionEffectType.SLOWNESS));settle(b);var applied=owned("effect.applied");assertEquals(cancelled?2:5,applied.size(),"Only real confirmed equipped passive and accepted native AI layers need original application owners; cancelled calls are not application success");assertEquals(Set.of("speed_i","low_light_vision"),applied.stream().filter(row->row.getAsJsonObject("data").has("passive_id")).map(row->row.getAsJsonObject("data").get("passive_id").getAsString()).collect(java.util.stream.Collectors.toSet()));for(var row:applied){var values=row.getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertEquals(shell.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(fixture.custody.operation().toString(),values.get("custody_operation_id").getAsString());assertTrue(values.has("application_id"));assertTrue(values.has("source_location"));assertTrue(values.has("target_location"));assertFalse(values.has("session_id"));assertFalse(row.has("playerid"));}
|
||||
assertEquals(cancelled?Set.of():Set.of("hex","stone_skin","frost_nova"),applied.stream().filter(row->row.getAsJsonObject("data").has("ability_id")).map(row->row.getAsJsonObject("data").get("ability_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
for(var id:List.of("hex","stone_skin","frost_nova")){var result=owned("ability.resolved").stream().filter(row->row.getAsJsonObject("data").get("ability_id").getAsString().equals(id)).findFirst().orElseThrow();assertEquals(cancelled?"cancelled":"accepted",result.getAsJsonObject("data").get("native_effect_outcome").getAsString());}
|
||||
if(cancelled){for(var id:List.of("hex","stone_skin","frost_nova")){var result=owned("ability.resolved").stream().filter(row->row.getAsJsonObject("data").get("ability_id").getAsString().equals(id)).findFirst().orElseThrow();assertEquals("cancelled",result.getAsJsonObject("data").get("native_effect_outcome").getAsString());assertTrue(result.getAsJsonObject("data").get("cooldown_committed").getAsBoolean(),"Existing native call-based cooldown decisions are not changed by observation");}}
|
||||
else{for(var type:List.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS)){var old=b.playerEffects.get(type);var event=new EntityPotionEffectEvent(b.actor.player,old,null,shell.entity,EntityPotionEffectEvent.Cause.EXPIRATION,EntityPotionEffectEvent.Action.REMOVED,false);b.f.dispatcher.accept(event);assertFalse(event.isCancelled());b.playerEffects.remove(type);}settle(b);assertEquals(2,owned("effect.ended").size(),"Accepted real expiration releases each original AI target layer once");}
|
||||
b.actor.core.location.setX(100);b.shared.pulse();assertFalse(shell.entity.isValid());settle(b);assertEquals(unrelated,b.playerEffects.get(PotionEffectType.SATURATION));var ended=owned("effect.ended");assertEquals(applied.size(),ended.size(),"Actual expiration plus native shell lifetime end account for original owned layers without pulse duplicates");for(var row:applied)assertEquals(1,ended.stream().filter(end->end.getAsJsonObject("data").get("application_id").equals(row.getAsJsonObject("data").get("application_id"))).count());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")));
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.api.Test
|
||||
void actualUnrelatedStrongerNativeReplacementEndsOnlyTheOriginalPassiveAndPreservesTheNewLayer()throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var layers=b.nativeEffects.get(shell.entity.getUniqueId());settle(b);
|
||||
var original=layers.get(PotionEffectType.SPEED);assertNotNull(original);assertEquals(2,owned("effect.applied").size());
|
||||
var stronger=new PotionEffect(PotionEffectType.SPEED,200,2,false,false);
|
||||
var replacement=new EntityPotionEffectEvent(shell.entity,original,stronger,shell.entity,EntityPotionEffectEvent.Cause.PLUGIN,EntityPotionEffectEvent.Action.CHANGED,true);
|
||||
b.f.dispatcher.accept(replacement);assertFalse(replacement.isCancelled());layers.put(PotionEffectType.SPEED,stronger);settle(b);
|
||||
var applied=owned("effect.applied");var ended=owned("effect.ended");assertEquals(2,applied.size());assertEquals(1,ended.size());assertEquals(applied.stream().filter(row->"speed_i".equals(row.getAsJsonObject("data").get("passive_id").getAsString())).findFirst().orElseThrow().getAsJsonObject("data").get("application_id"),ended.getFirst().getAsJsonObject("data").get("application_id"));
|
||||
assertEquals(stronger,layers.get(PotionEffectType.SPEED));b.actor.core.location.setX(100);b.shared.pulse();settle(b);assertEquals(stronger,layers.get(PotionEffectType.SPEED));assertEquals(2,owned("effect.ended").size());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.api.Test
|
||||
void originalNativeExpirationCapturesItsOwnTimeAndTargetLocationBeforeDeferredConfirmationNotApplicationOrLaterMovement()throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var observed=new ArrayList<EntityPotionEffectEvent>();nativePotionPort(b,b.actor.player,b.playerEffects,shell.entity,false,observed,Set.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS));b.actor.damage=(amount,source)->{};fixture.damage(b.actor,shell,3,false);b.shared.pulse();settle(b);var application=owned("effect.applied").stream().filter(row->row.getAsJsonObject("data").has("ability_id")&&row.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).findFirst().orElseThrow();assertEquals(17.5,application.getAsJsonObject("data").getAsJsonObject("target_location").get("x").getAsDouble());b.actor.core.location.setX(17.8);var original=b.playerEffects.get(PotionEffectType.WEAKNESS);var before=java.time.Instant.now();var event=new EntityPotionEffectEvent(b.actor.player,original,null,shell.entity,EntityPotionEffectEvent.Cause.EXPIRATION,EntityPotionEffectEvent.Action.REMOVED,false);b.f.dispatcher.accept(event);assertFalse(event.isCancelled());b.playerEffects.remove(PotionEffectType.WEAKNESS);var after=java.time.Instant.now();b.actor.core.location.setX(100);settle(b);var ended=owned("effect.ended");assertEquals(1,ended.size());var values=ended.getFirst().getAsJsonObject("data");assertEquals(application.getAsJsonObject("data").get("application_id"),values.get("application_id"));assertEquals(17.8,values.getAsJsonObject("target_location").get("x").getAsDouble(),"Original native end location is neither application location nor deferred/current player location");var time=java.time.Instant.parse(ended.getFirst().get("time").getAsString());assertFalse(time.isBefore(before),"End timestamp belongs to original native expiration, not original application");assertFalse(time.isAfter(after),"Deferred native confirmation cannot replace original end time");assertEquals(17.5,values.getAsJsonObject("application_target_location").get("x").getAsDouble());assertEquals("system",values.get("actor_kind").getAsString());assertFalse(values.has("session_id"));assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.api.Test
|
||||
void priorCancelledNativeRemovalCannotEndOrReplaceTheOriginalPassiveApplication()throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var layers=b.nativeEffects.get(shell.entity.getUniqueId());var original=layers.get(PotionEffectType.SPEED);settle(b);var event=new EntityPotionEffectEvent(shell.entity,original,null,shell.entity,EntityPotionEffectEvent.Cause.PLUGIN,EntityPotionEffectEvent.Action.REMOVED,false);event.setCancelled(true);b.f.dispatcher.accept(event);settle(b);assertTrue(event.isCancelled());assertEquals(original,layers.get(PotionEffectType.SPEED));assertEquals(2,owned("effect.applied").size());assertTrue(owned("effect.ended").isEmpty());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void optionalPostRemovalValidityFailureKeepsOriginalPendingLayerFactsAndOnlyActualLaterConfirmationEndsThem(boolean staysUnavailable)throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();settle(b);var applications=owned("effect.applied");assertEquals(2,applications.size());var removed=new java.util.concurrent.atomic.AtomicBoolean();doAnswer(call->{removed.set(true);return null;}).when(shell.entity).remove();doAnswer(call->{if(removed.get())throw new IllegalStateException("test-only optional post-removal validity unavailable");return true;}).when(shell.entity).isValid();b.actor.core.location.setX(100);var before=java.time.Instant.now();assertDoesNotThrow(b.shared::pulse,"An added optional native-effect end confirmation must not interrupt original native room cleanup after remove returned");var after=java.time.Instant.now();assertTrue(removed.get());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertFalse(b.directory.runView(b.definition.id(),b.quest.catalog()).orElseThrow().defeated());if(!staysUnavailable)doReturn(false).when(shell.entity).isValid();settle(b);
|
||||
var pending=owned("action.resolved").stream().filter(row->row.getAsJsonObject("data").has("phase")&&row.getAsJsonObject("data").get("phase").getAsString().equals("native_effect_end_confirmation_pending")).toList();assertEquals(2,pending.size(),"Actual unavailable lifetime confirmation needs qualified original application evidence, never orphaned or successful end");for(var row:pending){var facts=row.getAsJsonObject("data");assertEquals("unconfirmed",facts.get("outcome").getAsString());assertEquals("remove_returned_validity_unavailable",facts.get("end_status").getAsString());assertTrue(applications.stream().anyMatch(application->application.getAsJsonObject("data").get("application_id").equals(facts.get("application_id"))));var time=java.time.Instant.parse(row.get("time").getAsString());assertFalse(time.isBefore(before));assertFalse(time.isAfter(after));assertEquals(17.25,facts.getAsJsonObject("target_location").get("x").getAsDouble());}
|
||||
var ended=owned("effect.ended");assertEquals(staysUnavailable?0:2,ended.size(),"Only actual later native invalidity can confirm the original pending shell lifetime");for(var row:ended){var time=java.time.Instant.parse(row.get("time").getAsString());assertFalse(time.isBefore(before));assertFalse(time.isAfter(after));assertEquals("native_invalid_confirmed_after_original_remove",row.getAsJsonObject("data").get("reason").getAsString());}settle(b);assertEquals(staysUnavailable?0:2,owned("effect.ended").size());assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")));doReturn(false).when(shell.entity).isValid();
|
||||
}
|
||||
}
|
||||
@ParameterizedTest @ValueSource(strings={"unrelated_nested","unchanged_existing"})
|
||||
void actualNullSourcePotionCallsCannotAttributeAnUnrelatedDamageCallbackOrAnUnchangedPriorLayer(String mode)throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var observed=new ArrayList<EntityPotionEffectEvent>();nativePotionPort(b,shell.entity,b.nativeEffects.get(shell.entity.getUniqueId()),shell.entity,false,observed,Set.of(PotionEffectType.RESISTANCE));nativePotionPort(b,b.actor.player,b.playerEffects,shell.entity,false,observed,Set.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS,PotionEffectType.LUCK));if(mode.equals("unchanged_existing"))b.playerEffects.put(PotionEffectType.SLOWNESS,new PotionEffect(PotionEffectType.SLOWNESS,40,0,false,true));b.actor.damage=(amount,source)->{if(mode.equals("unrelated_nested"))b.actor.player.addPotionEffect(new PotionEffect(PotionEffectType.LUCK,100,1,false,false),true);};fixture.damage(b.actor,shell,3,false);b.shared.pulse();settle(b);assertTrue(observed.stream().allMatch(event->event.getSource()==null),"Pinned Bukkit addPotionEffect dispatches null native sources");assertTrue(b.playerEffects.containsKey(PotionEffectType.WEAKNESS));assertTrue(b.playerEffects.containsKey(PotionEffectType.SLOWNESS));assertTrue(b.nativeEffects.get(shell.entity.getUniqueId()).containsKey(PotionEffectType.RESISTANCE));assertEquals(mode.equals("unchanged_existing")?4:5,owned("effect.applied").size(),"Only actual changed intended native layers may be attributed to their exact cast");assertTrue(owned("effect.applied").stream().noneMatch(row->{var values=row.getAsJsonObject("data");return values.has("ability_id")&&values.get("ability_id").getAsString().equals("fireball");}),"An unrelated reentrant damage callback cannot become Fireball's potion application");if(mode.equals("unchanged_existing"))assertTrue(owned("effect.applied").stream().noneMatch(row->{var values=row.getAsJsonObject("data");return values.has("ability_id")&&values.get("ability_id").getAsString().equals("frost_nova");}));assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
static void nativePotionPort(DungeonAuditNativeFixture b,LivingEntity target,Map<PotionEffectType,PotionEffect> layers,Entity source,boolean cancelled,List<EntityPotionEffectEvent> observed,Set<PotionEffectType> types){
|
||||
doAnswer(call->{PotionEffect effect=call.getArgument(0);if(!types.contains(effect.getType())){layers.put(effect.getType(),effect);return true;}var old=layers.get(effect.getType());var event=new EntityPotionEffectEvent(target,old,effect,null,EntityPotionEffectEvent.Cause.PLUGIN,old==null?EntityPotionEffectEvent.Action.ADDED:EntityPotionEffectEvent.Action.CHANGED,true);event.setCancelled(cancelled);b.f.dispatcher.accept(event);observed.add(event);if(!event.isCancelled())layers.put(effect.getType(),effect);return !event.isCancelled();}).when(target).addPotionEffect(any(PotionEffect.class),anyBoolean());
|
||||
}
|
||||
private List<com.google.gson.JsonObject> owned(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)&&row.getAsJsonObject("data").has("entity_role")&&row.getAsJsonObject("data").get("entity_role").getAsString().equals("character_boss")).toList();}
|
||||
private static void settle(DungeonAuditNativeFixture b)throws Exception{Runnable reply;while((reply=b.f.tasks.poll())!=null)reply.run();fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));}
|
||||
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.bukkit.damage.DamageType;
|
||||
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.entity.EntityKnockbackEvent;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class CharacterBossMeleePassiveNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@ParameterizedTest @org.junit.jupiter.params.provider.CsvSource({"accepted,ENTITY_ATTACK","cancelled_knockback,ENTITY_ATTACK","unchanged_velocity,ENTITY_ATTACK","native_event_unavailable,ENTITY_ATTACK","accepted,MAGIC","cancelled_knockback,MAGIC","unchanged_velocity,MAGIC","native_event_unavailable,MAGIC","late_cancelled_damage,ENTITY_ATTACK","late_cancelled_damage,MAGIC"})
|
||||
void actualEligibleNativeDamageKnockbackPreservesOriginalCausePolicyAndConfirmsOnlyFinalNativeDecisionAndVelocity(String mode,String cause) throws Exception {
|
||||
try (var fixture=new CharacterBossAuditNativeFixture(data,List.of("stick_knockback","speed_i","low_light_vision"))) {
|
||||
var b=fixture.base; fixture.authorAndAccept(); b.enter(b.boss); var shell=fixture.shell();
|
||||
var velocity=new Vector[]{new Vector()}; var nativeCalls=new ArrayList<EntityKnockbackEvent>();
|
||||
doAnswer(call->{if(mode.equals("native_event_unavailable"))return null;var event=new EntityKnockbackEvent(b.actor.player,EntityKnockbackEvent.KnockbackCause.ENTITY_ATTACK,1,velocity[0].clone(),new Vector(0,0,1)); b.f.dispatcher.accept(event);nativeCalls.add(event);if(!event.isCancelled()&&!mode.equals("unchanged_velocity"))velocity[0]=event.getFinalKnockback().clone();return null;}).when(b.actor.player).knockback(anyDouble(),anyDouble(),anyDouble());
|
||||
doAnswer(call->velocity[0].clone()).when(b.actor.player).getVelocity();
|
||||
b.f.dispatcher.register(new Listener(){@EventHandler(priority=EventPriority.HIGHEST)public void veto(EntityKnockbackEvent event){if(mode.equals("cancelled_knockback"))event.setCancelled(true);}@EventHandler(priority=EventPriority.MONITOR)public void finalDamageDecision(EntityDamageByEntityEvent event){if(mode.equals("late_cancelled_damage")&&event.getDamager()==shell.entity)event.setCancelled(true);}},b.f.plugin);
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(shell.entity);when(source.getDirectEntity()).thenReturn(shell.entity);
|
||||
var refused=new EntityDamageByEntityEvent(shell.entity,b.actor.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);refused.setCancelled(true);b.f.dispatcher.accept(refused);
|
||||
b.actor.core.location.setX(100);var ineligible=new EntityDamageByEntityEvent(shell.entity,b.actor.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);b.f.dispatcher.accept(ineligible);assertTrue(ineligible.isCancelled());b.actor.core.location.setX(17.5);
|
||||
assertTrue(nativeCalls.isEmpty(),"Prior cancellation and room refusal never invoke native passive knockback");
|
||||
var accepted=new EntityDamageByEntityEvent(shell.entity,b.actor.player,EntityDamageEvent.DamageCause.valueOf(cause),source,3);b.f.dispatcher.accept(accepted);assertEquals(mode.equals("late_cancelled_damage"),accepted.isCancelled());assertEquals(mode.equals("native_event_unavailable")?0:1,nativeCalls.size());verify(b.actor.player,times(1)).knockback(eq(1d),anyDouble(),anyDouble());
|
||||
assertEquals((mode.equals("accepted")||mode.equals("late_cancelled_damage"))?1:0,velocity[0].getZ());assertEquals(fixture.template,b.directory.custody(fixture.template.id()).orElseThrow().character());settle(b);
|
||||
var damage=owned("combat.damage");assertEquals(1,damage.size(),"Actual accepted native melee needs a distinct system-shell damage observation, not an ability/basic-stick copy");
|
||||
var facts=damage.getFirst().getAsJsonObject("data");assertEquals(cause.equals("ENTITY_ATTACK")?"native_melee":"native_entity_damage",facts.get("damage_source").getAsString());assertEquals(cause,facts.get("damage_cause").getAsString());assertEquals(3,facts.get("final_damage").getAsDouble());assertEquals(mode.equals("late_cancelled_damage")?"cancelled":"accepted",facts.get("outcome").getAsString());assertEquals("system",facts.get("actor_kind").getAsString());assertEquals(b.actor.core.record.id().toString(),facts.get("target_character_id").getAsString());assertEquals(fixture.template.id().toString(),facts.get("subject_character_id").getAsString());assertFalse(damage.getFirst().has("playerid"));assertFalse(facts.has("session_id"));
|
||||
var applied=owned("effect.applied").stream().filter(row->row.getAsJsonObject("data").has("passive_id")&&"stick_knockback".equals(row.getAsJsonObject("data").get("passive_id").getAsString())).toList();
|
||||
assertEquals((mode.equals("accepted")||mode.equals("late_cancelled_damage"))?1:0,applied.size(),"A returned native knockback call is not confirmed application after cancellation/no velocity change");
|
||||
if(!applied.isEmpty()){var effect=applied.getFirst().getAsJsonObject("data");assertEquals("accepted",effect.get("outcome").getAsString());assertEquals(facts.get("operation_id").getAsString(),effect.get("melee_damage_operation_id").getAsString());assertEquals(1,effect.getAsJsonObject("velocity_after").get("z").getAsDouble());assertEquals(shell.entity.getUniqueId().toString(),effect.get("native_entity_id").getAsString());assertEquals(b.actor.core.id.toString(),effect.get("target_player_id").getAsString());assertFalse(applied.getFirst().has("playerid"));}
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void registeredDowningPreservesOriginalHealthStealEligibilityRefusalWithoutInventingHeal(boolean cancelled) throws Exception {
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data,List.of("health_steal","speed_i","low_light_vision"))){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();shell.entity.setHealth(50);
|
||||
var calls=new ArrayList<EntityRegainHealthEvent>();b.f.dispatcher.register(new Listener(){@EventHandler(priority=EventPriority.HIGHEST)public void adjust(EntityRegainHealthEvent event){if(event.getEntity()!=shell.entity)return;calls.add(event);event.setAmount(4);event.setCancelled(cancelled);}},b.f.plugin);
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(shell.entity);when(source.getDirectEntity()).thenReturn(shell.entity);
|
||||
var hit=new EntityDamageByEntityEvent(shell.entity,b.actor.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,5);b.f.dispatcher.accept(hit);assertFalse(hit.isCancelled());doReturn(hit).when(b.actor.player).getLastDamageCause();
|
||||
var deathSource=new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(DamageType.GENERIC)));
|
||||
var death=new PlayerDeathEvent(b.actor.player,deathSource,new ArrayList<>(),0,net.kyori.adventure.text.Component.empty(),true);b.f.dispatcher.accept(death);
|
||||
assertTrue(death.isCancelled(),"Existing registered fantasy downing contains the original native death; never suppress this guard for an audit test");
|
||||
assertTrue(calls.isEmpty(),"Registered downing removes original eligibility before Health Steal; audit cannot bypass it or invent a callback");assertEquals(50,shell.health[0]);assertEquals(fixture.template,b.directory.custody(fixture.template.id()).orElseThrow().character());settle(b);
|
||||
assertTrue(owned("combat.healed").isEmpty(),"No native regain or health change may become Health Steal success");assertTrue(owned("ability.cast").isEmpty());
|
||||
}
|
||||
}
|
||||
private List<com.google.gson.JsonObject> owned(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)&&row.getAsJsonObject("data").has("entity_role")&&row.getAsJsonObject("data").get("entity_role").getAsString().equals("character_boss")).toList();}
|
||||
private static void settle(DungeonAuditNativeFixture b)throws Exception{Runnable task;while((task=b.f.tasks.poll())!=null)task.run();fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));}
|
||||
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.events.CharacterBossEventBridge;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CharacterBossMetadataSafetyNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void optionalActorCaptureFailureCannotEraseAuthenticatedAdministratorOrReclassifyRegisteredCustodyAsSystem() throws Exception {
|
||||
try(var fixture=new CampAuditNativeFixture(data)) {
|
||||
var f=fixture.f;var actor=fixture.actor;
|
||||
var source=(games.dmg.fantasystick.dungeon.DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");
|
||||
var owner=(CharacterBossEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterBossEvents");
|
||||
var capture=CharacterBossEventBridge.class.getDeclaredField("capture");capture.setAccessible(true);
|
||||
capture.set(owner,(java.util.function.Function<org.bukkit.command.CommandSender,games.dmg.fantasystick.events.CampEventBridge.Actor>)sender->{throw new IllegalStateException("test-only optional capture unavailable");});
|
||||
command(f,actor,"dungeon","create","Original Capture Failure");var dungeon=source.snapshot().dungeons().getFirst();
|
||||
command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Room");command(f,actor,"dungeon","room","boss","mob","husk","Original Guard");
|
||||
var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);
|
||||
command(f,actor,"dungeon","room","boss","assign-sheet","1.125");assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());assertEquals(actor.core.record.id(),source.custody(actor.core.record.id()).orElseThrow().character().id());
|
||||
command(f,actor,"dungeon","edit","done");actor.core.location.setX(100);command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());assertTrue(source.custody(actor.core.record.id()).isEmpty());
|
||||
fence(FantasyStickLifecycleNativeTest.Fixture.field(owner,"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog"));
|
||||
for(var type:List.of("dungeon.character_boss.assigned","dungeon.character_boss.recovered")) {
|
||||
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();assertEquals(1,rows.size());
|
||||
var row=rows.getFirst();assertEquals("player",row.getAsJsonObject("data").get("actor_kind").getAsString(),"Optional metadata failure cannot turn the authenticated administrator into a system actor");assertTrue(row.has("playerid"));assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertFalse(row.getAsJsonObject("data").has("session_id"));assertFalse(row.getAsJsonObject("data").has("actor_location"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void oneUnavailablePassiveLayerReadCannotHideAnotherConfirmedEquippedNativeLayer() throws Exception {
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)) {
|
||||
var b=fixture.base;fixture.authorAndAccept();var reads=new java.util.concurrent.atomic.AtomicInteger();
|
||||
b.shared.nativeSpawnOutcome=entity->{if(entity.getType()==EntityType.VINDICATOR)doAnswer(call->{if(reads.incrementAndGet()==1)throw new IllegalStateException("test-only first passive observation unavailable");return new ArrayList<>(b.nativeEffects.get(entity.getUniqueId()).values());}).when(entity).getActivePotionEffects();};
|
||||
assertDoesNotThrow(()->b.enter(b.boss));var shell=fixture.shell();assertTrue(shell.entity.isValid());assertTrue(b.nativeEffects.get(shell.entity.getUniqueId()).containsKey(PotionEffectType.SPEED));assertTrue(b.nativeEffects.get(shell.entity.getUniqueId()).containsKey(PotionEffectType.NIGHT_VISION));
|
||||
fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));
|
||||
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("effect.applied")&&row.getAsJsonObject("data").has("passive_id")).toList();assertEquals(1,rows.size(),"A failed optional Speed confirmation cannot hide independently confirmed Night Vision");assertEquals("low_light_vision",rows.getFirst().getAsJsonObject("data").get("passive_id").getAsString());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void authenticVeryLargePersistedStatReachesBoundedOriginalCustodyAndNativeCastProofThenExactOfflineReplay() throws Exception {
|
||||
var huge=BigInteger.ONE.shiftLeft(60_000);List<java.util.Properties> originals;byte[] definitions,character;Path subjectFile;String expected;
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data,List.of("speed_i","low_light_vision","quicker_regeneration_i"),Map.of(Stat.INT,huge))) {
|
||||
var b=fixture.base;((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog")).close();fixture.authorAndAccept();subjectFile=data.resolve("characters").resolve(fixture.template.id()+".yml");character=Files.readAllBytes(subjectFile);b.enter(b.boss);var shell=fixture.shell();assertTrue(shell.entity.isValid());assertEquals(70,shell.health[0]);b.actor.damage=(amount,source)->{};fixture.damage(b.actor,shell,3,false);b.shared.pulse();assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());expected=huge.multiply(BigInteger.TWO).toString();
|
||||
b.f.plugin.setEnabled(false);b.f.awaitWorkers();WitchAuditTestSupport.drain(b.f,"characterBossDrain");originals=WitchAuditTestSupport.intents(data.resolve("character-boss-event-intents"));
|
||||
for(var type:List.of("dungeon.character_boss.assigned","dungeon.entity.spawned","ability.cast","ability.resolved"))assertTrue(originals.stream().anyMatch(p->p.getProperty("type").equals(type)),"Supported exact large stat must not drop every independent owner: "+type);
|
||||
try(var paths=Files.list(data.resolve("character-boss-event-intents"))) {for(var path:paths.filter(p->p.toString().endsWith(".pending")).toList())assertTrue(Files.size(path)<=16_384,"Shared durable intent bound stays unchanged");}
|
||||
definitions=Files.readAllBytes(data.resolve("dungeons.yml"));assertArrayEquals(character,Files.readAllBytes(subjectFile));
|
||||
}
|
||||
var failures=new ArrayList<Throwable>();
|
||||
try(var writer=LocalEventLog.open(data,java.time.Clock.systemUTC(),new LocalEventLog.RuntimeInfo("offline metadata","pinned-native","26.2","test"),Math.max(128,originals.size()+32),1_048_576,message->{throw new AssertionError(message);})) {
|
||||
writer.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);var audit=new CharacterBossEventBridge(input->writer.emit(input).thenApply(delivery->{assertNotEquals(LocalEventLog.Delivery.REJECTED,delivery,"Offline exact-replay control must accept original "+input.id());return delivery;}),data,sender->{throw new AssertionError("No current actor reconstruction");},failures::add);try(audit){audit.replay();audit.replay();}audit.drained().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(writer,"worker")).submit(()->{}).get(120,java.util.concurrent.TimeUnit.SECONDS);
|
||||
}
|
||||
assertTrue(failures.isEmpty(),failures.toString());var rows=ReskillEventsNativeTest.records(data);
|
||||
for(var intent:originals){var matching=rows.stream().filter(row->row.get("id").getAsString().equals(intent.getProperty("id"))).toList();assertEquals(1,matching.size());WitchAuditTestSupport.assertOriginal(intent,matching.getFirst());}
|
||||
for(var type:List.of("dungeon.character_boss.assigned","dungeon.entity.spawned","ability.cast","ability.resolved")) {
|
||||
var matching=rows.stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();assertFalse(matching.isEmpty());
|
||||
var logical=matching.getFirst().getAsJsonObject("data").get("operation_id");var group=matching.stream().filter(row->row.getAsJsonObject("data").get("operation_id").equals(logical)).toList();
|
||||
var main=group.stream().filter(row->row.getAsJsonObject("data").get("event_part_index").getAsInt()==0).toList();assertEquals(1,main.size(),"Only part zero is the logical outcome");var head=main.getFirst();var headData=head.getAsJsonObject("data");assertEquals(group.size(),headData.get("event_part_count").getAsInt());assertEquals(6,headData.get("multiplied_stats_count").getAsInt());assertEquals(head.get("id").getAsString(),headData.get("logical_event_id").getAsString());assertEquals(java.util.stream.IntStream.range(0,group.size()).boxed().toList(),group.stream().map(row->row.getAsJsonObject("data").get("event_part_index").getAsInt()).sorted().toList());for(var row:group){assertEquals(head.get("time"),row.get("time"));assertEquals(headData.get("logical_event_id"),row.getAsJsonObject("data").get("logical_event_id"));assertEquals(headData.get("subject_character_id"),row.getAsJsonObject("data").get("subject_character_id"));if(row.getAsJsonObject("data").get("event_part_index").getAsInt()>0){for(var outcomeField:List.of("outcome","custody_status","sheet_status","cooldown_committed","application_id"))assertFalse(row.getAsJsonObject("data").has(outcomeField),"Decimal-only continuation is not another gameplay acknowledgement: "+outcomeField);}}
|
||||
for(var stat:List.of("STR","DEX","CON","WIS","CHR")){var values=group.stream().map(row->row.getAsJsonObject("data")).filter(value->value.has("multiplied_stats_fragment")&&stat.equals(value.getAsJsonObject("multiplied_stats_fragment").get("stat").getAsString())).toList();assertEquals(1,values.size());assertEquals("60",values.getFirst().getAsJsonObject("multiplied_stats_fragment").get("digits").getAsString());}
|
||||
var exact=new StringBuilder();
|
||||
var fragments=group.stream().map(row->row.getAsJsonObject("data")).filter(value->value.has("multiplied_stats_fragment")&&"INT".equals(value.getAsJsonObject("multiplied_stats_fragment").get("stat").getAsString())).sorted(java.util.Comparator.comparingInt(value->value.getAsJsonObject("multiplied_stats_fragment").get("offset").getAsInt())).toList();
|
||||
for(var value:fragments){var fragment=value.getAsJsonObject("multiplied_stats_fragment");assertEquals(exact.length(),fragment.get("offset").getAsInt());exact.append(fragment.get("digits").getAsString());assertEquals(expected.length(),fragment.get("total_digits").getAsInt());}
|
||||
assertEquals(expected,exact.toString(),"Linked bounded parts must retain every original multiplied digit, not truncation/current-template reconstruction");
|
||||
}
|
||||
assertArrayEquals(definitions,Files.readAllBytes(data.resolve("dungeons.yml")));assertArrayEquals(character,Files.readAllBytes(subjectFile));
|
||||
}
|
||||
private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String... args) throws Exception {DungeonAuthoringEventsNativeTest.command(f,actor,args);}
|
||||
private static void fence(Object owner) throws Exception {((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CharacterBossPassiveHealingNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
static final class RegainDecision implements Listener {
|
||||
final boolean cancel;final List<EntityRegainHealthEvent> calls=new ArrayList<>();final List<Double> requests=new ArrayList<>();
|
||||
RegainDecision(boolean cancel){this.cancel=cancel;}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void regain(EntityRegainHealthEvent event){
|
||||
if(event.getEntity().getType()!=org.bukkit.entity.EntityType.VINDICATOR)return;
|
||||
calls.add(event);requests.add(event.getAmount());event.setAmount(4);event.setCancelled(cancel);
|
||||
}
|
||||
}
|
||||
private void settings()throws Exception{Files.writeString(data.resolve("config.yml"),"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 120\nout-of-combat-healing-amount: 2\nquicker-regeneration-i-bonus-healing: 3\n");}
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void nativeOutOfCombatRegainWithEquippedBonusCapturesOriginalDecisionAndAppliedDelta(boolean cancelled)throws Exception{
|
||||
settings();try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();var decision=new RegainDecision(cancelled);b.f.dispatcher.register(decision,b.f.plugin);b.shared.nativeSpawnOutcome=entity->{if(entity.getType()==org.bukkit.entity.EntityType.VINDICATOR)entity.setHealth(50);};b.enter(b.boss);var shell=fixture.shell();
|
||||
assertEquals(1,decision.calls.size(),"Actual native OOC callback, not a cast or synthetic event");assertEquals(cancelled?50:54,shell.health[0]);
|
||||
assertEquals(fixture.template,b.directory.custody(fixture.template.id()).orElseThrow().character());
|
||||
settle(b);var healed=owned("combat.healed");assertEquals(1,healed.size(),"Native OOC regain has its independent character-boss system owner");var row=healed.getFirst();var facts=row.getAsJsonObject("data");
|
||||
assertEquals("system",facts.get("actor_kind").getAsString());assertFalse(row.has("playerid"));assertFalse(facts.has("session_id"));assertEquals(fixture.template.id().toString(),facts.get("subject_character_id").getAsString());assertEquals(fixture.custody.operation().toString(),facts.get("custody_operation_id").getAsString());assertEquals(shell.entity.getUniqueId().toString(),facts.get("native_entity_id").getAsString());assertEquals(shell.entity.getUniqueId().toString(),facts.get("target_entity_id").getAsString());assertEquals("quicker_regeneration_i",facts.get("passive_id").getAsString());assertEquals("out_of_combat_recovery",facts.get("healing_source").getAsString());assertEquals(5,facts.get("requested_amount").getAsDouble());assertEquals(4,facts.get("native_event_amount").getAsDouble());assertEquals(cancelled?0:4,facts.get("applied_amount").getAsDouble());assertEquals(cancelled?"cancelled":"accepted",facts.get("outcome").getAsString());assertEquals(50,facts.get("health_before").getAsDouble());assertEquals(cancelled?50:54,facts.get("health_after").getAsDouble());assertTrue(facts.has("source_location"));assertTrue(facts.has("target_location"));assertTrue(owned("ability.cast").isEmpty());
|
||||
b.shared.pulse();settle(b);assertEquals(1,decision.calls.size(),"Interval does not dispatch another regain callback");assertEquals(1,owned("combat.healed").size(),"An unchanged callback interval is not a second heal");
|
||||
}
|
||||
}
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void bothEquippedRegenerationBonusesKeepOriginalCombinedNativeRequestAndQualifiedComponentProvenance(boolean cancelled)throws Exception{
|
||||
settings();Files.writeString(data.resolve("config.yml"),"quicker-regeneration-ii-bonus-healing: 4\n",java.nio.file.StandardOpenOption.APPEND);
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data,List.of("quicker_regeneration_i","quicker_regeneration_ii","low_light_vision"))){var b=fixture.base;fixture.authorAndAccept();var decision=new RegainDecision(cancelled);b.f.dispatcher.register(decision,b.f.plugin);b.shared.nativeSpawnOutcome=entity->{if(entity.getType()==org.bukkit.entity.EntityType.VINDICATOR)entity.setHealth(50);};b.enter(b.boss);var shell=fixture.shell();assertEquals(List.of(9d),decision.requests);assertEquals(cancelled?50:54,shell.health[0]);assertEquals(fixture.template,b.directory.custody(fixture.template.id()).orElseThrow().character());settle(b);var healed=owned("combat.healed");assertEquals(1,healed.size());var facts=healed.getFirst().getAsJsonObject("data");assertEquals(9,facts.get("requested_amount").getAsDouble());assertEquals(cancelled?0:4,facts.get("applied_amount").getAsDouble());assertTrue(facts.has("recovery_base_amount"),"Actual I+II recovery needs captured original base/combined bonus facts, not I-only provenance");assertEquals("2",facts.get("recovery_base_amount").getAsString());assertEquals("7",facts.get("recovery_bonus_amount").getAsString());assertEquals(List.of("quicker_regeneration_i","quicker_regeneration_ii"),java.util.stream.StreamSupport.stream(facts.getAsJsonArray("recovery_bonus_passive_ids").spliterator(),false).map(com.google.gson.JsonElement::getAsString).toList());assertEquals("unavailable_from_combined_native_bonus_port",facts.get("bonus_component_amounts_status").getAsString());assertFalse(facts.has("quicker_regeneration_i_amount"));assertFalse(facts.has("quicker_regeneration_ii_amount"));b.shared.pulse();settle(b);assertEquals(1,decision.requests.size());assertEquals(1,owned("combat.healed").size());}
|
||||
}
|
||||
@Test void fullHealthAndUnharmedNativeAiDoesNotInventHealingCleansingOrBond()throws Exception{
|
||||
settings();try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var decision=new RegainDecision(false);b.f.dispatcher.register(decision,b.f.plugin);b.actor.damage=(amount,source)->{};
|
||||
fixture.damage(b.actor,shell,2,false);b.shared.pulse();settle(b);
|
||||
assertEquals(70,shell.health[0]);assertTrue(decision.calls.isEmpty());assertTrue(owned("combat.healed").isEmpty());assertTrue(owned("combat.damage").isEmpty(),"No dispatched native damage can become a successful combat row");assertTrue(owned("effect.applied").stream().noneMatch(row->"purify".equals(row.getAsJsonObject("data").has("ability_id")?row.getAsJsonObject("data").get("ability_id").getAsString():null)));
|
||||
var resolved=owned("ability.resolved");for(var id:List.of("second_wind","purify")){var matches=resolved.stream().filter(row->id.equals(row.getAsJsonObject("data").get("ability_id").getAsString())).toList();assertEquals(1,matches.size());assertEquals("no_op",matches.getFirst().getAsJsonObject("data").get("outcome").getAsString());assertFalse(matches.getFirst().getAsJsonObject("data").get("cooldown_committed").getAsBoolean());}
|
||||
assertTrue(owned("ability.cast").stream().noneMatch(row->"guardian_bond".equals(row.getAsJsonObject("data").get("ability_id").getAsString())));
|
||||
b.shared.pulse();settle(b);assertTrue(decision.calls.isEmpty());assertTrue(owned("combat.healed").isEmpty());for(var id:List.of("second_wind","purify")){var retries=owned("ability.resolved").stream().filter(row->id.equals(row.getAsJsonObject("data").get("ability_id").getAsString())).toList();assertEquals(2,retries.size(),"Refused full-health/no-harmful-effect attempt does not commit a cooldown");assertTrue(retries.stream().noneMatch(row->row.getAsJsonObject("data").get("cooldown_committed").getAsBoolean()));}assertEquals(fixture.template,b.directory.custody(fixture.template.id()).orElseThrow().character());int casts=owned("ability.cast").size();org.mockito.Mockito.doReturn(false).when(shell.entity).hasLineOfSight(b.actor.player);b.shared.pulse();settle(b);assertEquals(casts,owned("ability.cast").size(),"Unreachable target cannot manufacture another native AI cast");assertTrue(owned("combat.damage").isEmpty());assertTrue(owned("combat.healed").isEmpty());
|
||||
}
|
||||
}
|
||||
private List<com.google.gson.JsonObject> owned(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)&&row.getAsJsonObject("data").has("entity_role")&&row.getAsJsonObject("data").get("entity_role").getAsString().equals("character_boss")).toList();}
|
||||
private static void settle(DungeonAuditNativeFixture b)throws Exception{fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));}
|
||||
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CharacterBossRuntimeEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void registeredDistinctTemplateAssignmentAndActualGiverAcceptanceMaterializeMultipliedShellAsSystemActorNotFantasyPlayer()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characters");var before=storage.find(fixture.template.id()).get().orElseThrow();b.enter(b.boss);var shell=fixture.shell();assertTrue(shell.entity.isValid());assertEquals(70,shell.maximum[0]);assertEquals(26,shell.attack[0]);assertEquals(70,shell.health[0]);assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertEquals(before,storage.find(fixture.template.id()).get().orElseThrow());assertTrue(b.directory.runView(b.definition.id(),b.quest.catalog()).orElseThrow().defeatedSlots().isEmpty());fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));
|
||||
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("dungeon.entity.spawned")&&"character_boss".equals(row.getAsJsonObject("data").has("entity_role")?row.getAsJsonObject("data").get("entity_role").getAsString():null)).toList();assertEquals(1,rows.size(),"Actual loaded-valid multiplied character shell needs its independent system-owned variant, not generic run/native-boss/player evidence");var row=rows.getFirst();var values=row.getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertFalse(row.has("playerid"));assertFalse(row.has("characterid"));assertFalse(values.has("session_id"));assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(fixture.template.name(),values.get("subject_character_name").getAsString());assertEquals(fixture.custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals(shell.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals("VINDICATOR",values.get("entity_type").getAsString());assertEquals(b.definition.id().toString(),values.get("dungeon_id").getAsString());assertEquals(b.boss.id().toString(),values.get("room_id").getAsString());assertEquals(b.quest.catalog().toString(),values.get("generation_id").getAsString());assertEquals("2",values.get("multiplier").getAsString());assertEquals("60",values.getAsJsonObject("multiplied_stats").get("CON").getAsString());assertEquals(17.25,values.getAsJsonObject("spawn_location").get("x").getAsDouble());assertEquals("loaded_valid_registered_entity",values.get("materialization_status").getAsString());assertFalse(values.has("character_snapshot"));assertFalse(values.has("item"));assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(record->record.get("type").getAsString().endsWith("combat.reward_resolved")||record.get("type").getAsString().endsWith("dungeon.completed")));b.f.plugin.setEnabled(false);b.f.awaitWorkers();WitchAuditTestSupport.drain(b.f,"characterBossDrain");
|
||||
}
|
||||
}
|
||||
@Test void realEmptyRoomRemovalAndReentryUseOriginalShellFactsWithoutRewardOrCustodyMutation()throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var original=fixture.shell();b.actor.core.location.setX(100);b.shared.pulse();assertFalse(original.entity.isValid(),"The actual native empty-room cleanup removed the original shell");assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertFalse(b.directory.runView(b.definition.id(),b.quest.catalog()).orElseThrow().defeated());settle(b);
|
||||
var removed=owned("dungeon.entity.removed");assertEquals(1,removed.size(),"Real character-shell cleanup needs its own nonreward system observation, not ordinary-boss cleanup or silence");var values=removed.getFirst().getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertEquals(original.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(fixture.custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals("no_eligible_player_in_room",values.get("reason").getAsString());assertEquals(17.25,values.getAsJsonObject("removal_location").get("x").getAsDouble());assertFalse(removed.getFirst().has("playerid"));assertFalse(values.has("session_id"));
|
||||
b.enter(b.boss);var fresh=fixture.shell();assertNotEquals(original.entity.getUniqueId(),fresh.entity.getUniqueId());assertEquals(70,fresh.health[0]);assertNull(fresh.target[0]);settle(b);assertEquals(2,owned("dungeon.entity.spawned").size());assertEquals(1,owned("dungeon.entity.removed").size());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")));
|
||||
}
|
||||
}
|
||||
@Test void genuineRepresentedNativeShellDeathObservesAcknowledgedDefeatWithoutCopyingSoleKillCompletionOrTrophyOwner()throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var source=org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class);org.mockito.Mockito.when(source.getCausingEntity()).thenReturn(b.actor.player);org.mockito.Mockito.when(source.getDirectEntity()).thenReturn(b.actor.player);b.f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.actor.player,shell.entity,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));b.f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(shell.entity,source,new java.util.ArrayList<>(),0));ReskillEventsNativeTest.await(b.f,()->b.directory.runView(b.definition.id(),b.quest.catalog()).map(games.dmg.fantasystick.dungeon.DungeonState.Run::defeated).orElse(false));ReskillEventsNativeTest.await(b.f,()->{try{var rows=ReskillEventsNativeTest.records(data);return rows.stream().anyMatch(row->row.get("type").getAsString().endsWith("dungeon.completed"))&&rows.stream().anyMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved"));}catch(Exception pending){return false;}});assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertFalse(b.directory.runView(b.definition.id(),b.quest.catalog()).orElseThrow().clearedRooms().contains(b.entry.id()),"Actual boss death completes without fabricated ordinary-room kills");settle(b);
|
||||
var defeated=owned("dungeon.entity.defeated");assertEquals(1,defeated.size(),"Actual acknowledged native character-shell defeat needs its own original system observation, not copied reward/completion evidence");var values=defeated.getFirst().getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertEquals(shell.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(b.quest.catalog().toString(),values.get("generation_id").getAsString());assertEquals("acknowledged_boss_completion",values.get("settlement_status").getAsString());assertEquals(b.actor.core.id.toString(),values.get("triggering_player_id").getAsString());assertEquals(17.25,values.getAsJsonObject("death_location").get("x").getAsDouble());assertFalse(defeated.getFirst().has("characterid"));assertFalse(values.has("session_id"));assertFalse(values.has("xp"));assertFalse(values.has("coins"));assertTrue(owned("dungeon.entity.removed").isEmpty(),"The genuinely defeated shell is not also an empty-room survivor");var rows=ReskillEventsNativeTest.records(data);assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("combat.reward_resolved")&&row.getAsJsonObject("data").get("target_id").getAsString().equals(shell.entity.getUniqueId().toString())).count());assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("dungeon.completed")).count());assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.trophy_earned")&&"dungeon_trophy".equals(row.getAsJsonObject("data").get("trophy_type").getAsString())).count());assertEquals(fixture.template,((games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characters")).find(fixture.template.id()).get().orElseThrow());
|
||||
}
|
||||
}
|
||||
private java.util.List<com.google.gson.JsonObject> owned(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)&&row.getAsJsonObject("data").has("entity_role")&&row.getAsJsonObject("data").get("entity_role").getAsString().equals("character_boss")).toList();}
|
||||
private static void settle(DungeonAuditNativeFixture b)throws Exception{Runnable reply;while((reply=b.f.tasks.poll())!=null)reply.run();fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));}
|
||||
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.events.CharacterBossEventBridge;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class CharacterBossRuntimeReplayNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void rejectedWriterOriginalRuntimeAiNativeOutcomesAndLifecycleReplayTwiceOfflineWithoutRecreatingAuthority(boolean cancelledAndDefeated) throws Exception {
|
||||
List<Properties> originals;
|
||||
UUID subject;
|
||||
games.dmg.fantasystick.persistence.CharacterRecord template;
|
||||
games.dmg.fantasystick.dungeon.DungeonState originalState;
|
||||
var bytes=new LinkedHashMap<Path,byte[]>();
|
||||
FantasyStickLifecycleNativeTest.Actor originalActor;
|
||||
org.bukkit.inventory.ItemStack[] originalInventory;
|
||||
int originalMessages;
|
||||
java.util.List<org.bukkit.entity.LivingEntity> originalSpawns;
|
||||
int spawnCount;
|
||||
var folder=data.resolve("character-boss-event-intents");
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)) {
|
||||
var b=fixture.base;
|
||||
fixture.authorAndAccept();
|
||||
ReskillEventsNativeTest.await(b.f,()->{try{return ReskillEventsNativeTest.records(data).stream().anyMatch(row->row.get("type").getAsString().endsWith("dungeon.character_boss.assigned")&&row.has("playerid")&&row.get("playerid").getAsString().equals(b.actor.core.id.toString()));}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));
|
||||
subject=fixture.template.id();template=fixture.template;
|
||||
((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog")).close();
|
||||
b.enter(b.boss);var shell=fixture.shell();shell.entity.setHealth(50);
|
||||
var nativeDamage=new ArrayList<EntityDamageByEntityEvent>();
|
||||
b.actor.damage=(amount,causing)->{
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getCausingEntity()).thenReturn(causing);when(source.getDirectEntity()).thenReturn(causing);
|
||||
var event=new EntityDamageByEntityEvent(causing,b.actor.player,EntityDamageEvent.DamageCause.MAGIC,source,amount);
|
||||
event.setCancelled(cancelledAndDefeated);b.f.dispatcher.accept(event);nativeDamage.add(event);
|
||||
};
|
||||
var nativePotions=new ArrayList<EntityPotionEffectEvent>();
|
||||
CharacterBossEffectEventsNativeTest.nativePotionPort(b,b.actor.player,b.playerEffects,shell.entity,cancelledAndDefeated,nativePotions,Set.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS));
|
||||
CharacterBossEffectEventsNativeTest.nativePotionPort(b,shell.entity,b.nativeEffects.get(shell.entity.getUniqueId()),shell.entity,cancelledAndDefeated,nativePotions,Set.of(PotionEffectType.RESISTANCE));
|
||||
b.nativeEffects.get(shell.entity.getUniqueId()).put(PotionEffectType.WEAKNESS,new PotionEffect(PotionEffectType.WEAKNESS,200,0));
|
||||
var healing=new CharacterBossAbilityEventsNativeTest.NativeHealingGuard(cancelledAndDefeated);
|
||||
b.f.dispatcher.register(healing,b.f.plugin);
|
||||
fixture.damage(b.actor,shell,3,false);b.shared.pulse();
|
||||
assertEquals(2,nativeDamage.size());assertEquals(3,nativePotions.size());assertEquals(1,healing.observed.size());assertEquals(cancelledAndDefeated?50:55,shell.health[0]);
|
||||
if(!cancelledAndDefeated) {
|
||||
for(var type:List.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS)) {
|
||||
var old=b.playerEffects.get(type);
|
||||
b.f.dispatcher.accept(new EntityPotionEffectEvent(b.actor.player,old,null,shell.entity,EntityPotionEffectEvent.Cause.EXPIRATION,EntityPotionEffectEvent.Action.REMOVED,false));
|
||||
b.playerEffects.remove(type);
|
||||
}
|
||||
}
|
||||
settle(b);
|
||||
b.actor.core.location.setX(100);b.shared.pulse();assertFalse(shell.entity.isValid());settle(b);
|
||||
b.enter(b.boss);var fresh=fixture.shell();assertNotEquals(shell.entity.getUniqueId(),fresh.entity.getUniqueId());assertEquals(70,fresh.health[0]);assertNull(fresh.target[0]);
|
||||
if(cancelledAndDefeated) {
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(b.actor.player);when(source.getDirectEntity()).thenReturn(b.actor.player);
|
||||
b.f.dispatcher.accept(new EntityDamageByEntityEvent(b.actor.player,fresh.entity,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));
|
||||
b.f.dispatcher.accept(new EntityDeathEvent(fresh.entity,source,new ArrayList<>(),0));
|
||||
ReskillEventsNativeTest.await(b.f,()->b.directory.runView(b.definition.id(),b.quest.catalog()).map(games.dmg.fantasystick.dungeon.DungeonState.Run::defeated).orElse(false));
|
||||
}
|
||||
assertEquals(fixture.custody,b.directory.custody(subject).orElseThrow());
|
||||
assertEquals(template,((CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characters")).find(subject).get().orElseThrow());
|
||||
b.f.plugin.setEnabled(false);b.f.awaitWorkers();WitchAuditTestSupport.drain(b.f,"characterBossDrain");
|
||||
originals=WitchAuditTestSupport.intents(folder);
|
||||
var counts=originals.stream().collect(java.util.stream.Collectors.groupingBy(p->p.getProperty("type"),java.util.stream.Collectors.counting()));
|
||||
for(var type:List.of("dungeon.entity.spawned","dungeon.entity.removed","dungeon.character_boss.target_changed","ability.cast","ability.resolved","combat.damage","combat.healed","effect.applied","effect.ended")) assertTrue(counts.containsKey(type),"Actual original runtime intent missing: "+type);
|
||||
assertEquals(cancelledAndDefeated?1L:0L,counts.getOrDefault("dungeon.entity.defeated",0L));
|
||||
assertEquals(2L,counts.get("combat.damage"));assertEquals(1L,counts.get("combat.healed"));
|
||||
for(var intent:originals){assertEquals("ssystem",intent.getProperty("data.actor_kind"));assertNull(intent.getProperty("player"));assertNull(intent.getProperty("character"));assertNull(intent.getProperty("session"));assertNull(intent.getProperty("data.session_id"));}
|
||||
originalState=new games.dmg.fantasystick.persistence.YamlDungeonRepository(data).load();
|
||||
originalActor=b.actor;originalInventory=java.util.Arrays.stream(b.actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);originalMessages=b.actor.messages.size();originalSpawns=b.shared.spawned;spawnCount=originalSpawns.size();
|
||||
try(var paths=Files.walk(data)) {
|
||||
for(var path:paths.filter(p->Files.isRegularFile(p)&&p.toString().endsWith(".yml")&&!p.getFileName().toString().equals("config.yml")).toList())bytes.put(data.relativize(path),Files.readAllBytes(path));
|
||||
}
|
||||
}
|
||||
// Reopen only the independent owner/writer: a full plugin restart separately resumes US-085's
|
||||
// legitimate pending completion publication, and cannot isolate this owner's byte conservation.
|
||||
var failures=new ArrayList<Throwable>();
|
||||
try(var writer=LocalEventLog.open(data,java.time.Clock.systemUTC(),new LocalEventLog.RuntimeInfo("offline metadata","pinned-native","26.2","test"),128,1_048_576,message->{throw new AssertionError(message);})) {
|
||||
writer.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var audit=new CharacterBossEventBridge(writer::emit,data,sender->{throw new AssertionError("Offline replay must not capture current actor/native authority");},failures::add);
|
||||
try(audit) {audit.replay();audit.replay();}
|
||||
audit.drained().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
}
|
||||
assertTrue(failures.isEmpty(),failures.toString());
|
||||
var rows=ReskillEventsNativeTest.records(data);
|
||||
for(var intent:originals){var matching=rows.stream().filter(row->row.get("id").getAsString().equals(intent.getProperty("id"))).toList();assertEquals(1,matching.size());WitchAuditTestSupport.assertOriginal(intent,matching.getFirst());assertEquals("system",matching.getFirst().getAsJsonObject("data").get("actor_kind").getAsString());assertFalse(matching.getFirst().has("playerid"));assertFalse(matching.getFirst().has("characterid"));assertFalse(matching.getFirst().getAsJsonObject("data").has("session_id"));}
|
||||
assertEquals(originalState,new games.dmg.fantasystick.persistence.YamlDungeonRepository(data).load());
|
||||
assertEquals(template,originalState.dungeons().getFirst().rooms().stream().filter(room->room.boss()!=null&&room.boss().character()!=null).findFirst().orElseThrow().boss().character().character());
|
||||
for(var entry:bytes.entrySet())assertArrayEquals(entry.getValue(),Files.readAllBytes(data.resolve(entry.getKey())),"Replay cannot rewrite original authority: "+entry.getKey());
|
||||
assertArrayEquals(originalInventory,originalActor.core.inventory.getContents());assertEquals(originalMessages,originalActor.messages.size());assertEquals(spawnCount,originalSpawns.size(),"Independent offline audit replay cannot materialize any encounter");
|
||||
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")),"Independent character owner cannot export another owner's original receipts or re-earn rewards");
|
||||
}
|
||||
private static void settle(DungeonAuditNativeFixture b) throws Exception {
|
||||
Runnable reply;while((reply=b.f.tasks.poll())!=null)reply.run();
|
||||
fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));
|
||||
}
|
||||
private static void fence(Object owner) throws Exception {((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CharacterBossTargetEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void realAcceptedThreatFixatesDespiteLaterHigherThreatThenRetargetsWithOriginalNativeSystemFactsAndNoPulseSpam()throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();var challenger=fixture.acceptedChallenger(data);b.actor.damage=(amount,source)->{};b.enter(b.boss);var shell=fixture.shell();fixture.damage(b.actor,shell,100,true);b.shared.pulse();assertNull(shell.target[0],"Prior native cancellation cannot establish accepted threat or a fantasy target");fixture.damage(b.actor,shell,3,false);b.shared.pulse();assertSame(b.actor.player,shell.target[0]);fixture.damage(challenger,shell,10,false);b.shared.pulse();assertSame(b.actor.player,shell.target[0],"Later higher accepted threat cannot steal actual fixation");b.actor.core.location.setX(100);b.shared.pulse();assertSame(challenger.player,shell.target[0],"Actual ineligible fixation retargets highest remaining eligible threat");for(int i=0;i<3;i++)b.shared.pulse();assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("dungeon.character_boss.target_changed")).toList();assertEquals(2,rows.size(),"Actual initial threat fixation and ineligible retarget need original system-owned transitions, not pulse/refusal spam or later actor reconstruction");
|
||||
var initial=rows.stream().filter(row->row.getAsJsonObject("data").get("target_player_id").getAsString().equals(b.actor.core.id.toString())).findFirst().orElseThrow();var retarget=rows.stream().filter(row->row.getAsJsonObject("data").get("target_player_id").getAsString().equals(challenger.core.id.toString())).findFirst().orElseThrow();assertEquals("highest_accepted_threat",initial.getAsJsonObject("data").get("reason").getAsString());assertEquals("fixation_invalidated_retarget",retarget.getAsJsonObject("data").get("reason").getAsString());assertEquals(b.actor.core.record.id().toString(),initial.getAsJsonObject("data").get("target_character_id").getAsString());assertEquals(challenger.core.record.id().toString(),retarget.getAsJsonObject("data").get("target_character_id").getAsString());assertEquals(17.5,initial.getAsJsonObject("data").getAsJsonObject("target_location").get("x").getAsDouble());assertEquals(17.75,retarget.getAsJsonObject("data").getAsJsonObject("target_location").get("x").getAsDouble());assertEquals(3,initial.getAsJsonObject("data").get("accepted_threat").getAsDouble());assertEquals(10,retarget.getAsJsonObject("data").get("accepted_threat").getAsDouble());assertEquals(b.actor.core.id.toString(),retarget.getAsJsonObject("data").get("previous_target_player_id").getAsString());for(var row:rows){var values=row.getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(shell.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals(fixture.custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals(b.quest.catalog().toString(),values.get("generation_id").getAsString());assertEquals(17.25,values.getAsJsonObject("source_location").get("x").getAsDouble());assertFalse(values.has("session_id"));assertFalse(row.has("playerid"));assertFalse(row.has("characterid"));}
|
||||
}
|
||||
}
|
||||
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user