feat(dungeon): export original authoring and runtime audit events
Release / release (push) Failing after 13m39s
Release / release (push) Failing after 13m39s
This commit is contained in:
1 parent
c6252e8805
commit
de5391e8e4
17 files changed
+728
-43
No files matched your search
@@ -189,6 +189,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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.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;
|
||||
@@ -924,6 +926,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
eventLog::emit, Clock.systemUTC()));
|
||||
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);
|
||||
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);
|
||||
@@ -1136,6 +1148,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous hard-mob XP event drain failed", failure);
|
||||
if (replayHardMobXpEvents != null) replayHardMobXpEvents.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);
|
||||
@@ -1266,6 +1283,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
|
||||
var closingKnockbackEvents = stickKnockbackEvents; stickKnockbackEvents = null;
|
||||
var closingHardMobXpEvents = hardMobXpAuditEvents; hardMobXpAuditEvents = null;
|
||||
var closingDungeonOwnerEvents = dungeonOwnerEvents; dungeonOwnerEvents = null;
|
||||
var closingCampEvents = campAuditEvents; campAuditEvents = null;
|
||||
var closingChampionEvents = championAuditEvents; championAuditEvents = null;
|
||||
var closingDayResetEvents = adventureDayResetEvents; adventureDayResetEvents = null;
|
||||
@@ -1648,6 +1666,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingHardMobXpEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingHardMobXpEvents.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();
|
||||
|
||||
@@ -105,6 +105,10 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
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));}
|
||||
@@ -126,7 +130,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
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;}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);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");}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);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 changed.":"Quest acceptance is pending safe cancellation; retry after recovery.")));return;}var location=directory.findSite(site).orElseThrow();if(campAudit!=null)campAudit.questTransition("adventure.quest.accepted",operation,campActor,quest,result.capturedSite().orElse(null),type==AdventureQuestState.UnitType.PARTY?context.character:null,"accepted");if(championAudit!=nLine truncated
|
||||
@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();}
|
||||
|
||||
@@ -36,6 +36,10 @@ public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
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());}
|
||||
}
|
||||
@@ -71,7 +75,9 @@ public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
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,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 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());}
|
||||
@@ -142,22 +148,25 @@ public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
}
|
||||
private void observeQuestCommit(AdventureQuestState before,Result<?> result,String reason,UUID operation){
|
||||
try{
|
||||
if(reason.equals("player_abandoned"))return;
|
||||
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(null);
|
||||
if(definition==null||(definition.type()!=AdventureQuestState.SiteType.CAMP&&definition.type()!=AdventureQuestState.SiteType.CHAMPION))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":
|
||||
find(after.sites(),quest.site()).isEmpty()?"site_removed":result.publication()!=null?
|
||||
!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=definition.type()==AdventureQuestState.SiteType.CHAMPION?championExpiryObserver:expiryObserver;
|
||||
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);}
|
||||
|
||||
@@ -17,34 +17,39 @@ 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 games.dmg.fantasystick.events.DungeonEventBridge auditEvents;
|
||||
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;
|
||||
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 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 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();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 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;}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 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 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,10 +32,12 @@ 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 entity,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,defeatedBoss;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 games.dmg.fantasystick.events.DungeonAuditEventBridge ownerEvents;
|
||||
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;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 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 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;
|
||||
@@ -65,30 +67,37 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
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;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 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);}}
|
||||
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);}}
|
||||
@@ -130,10 +139,11 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
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 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);}}}
|
||||
@@ -142,7 +152,7 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
@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)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){var origin=encounterOrigin(event.getEntity());if(origin.isEmpty())return;captureBossDefeat(attempt,event.getEntity(),room);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;attempt.entities.remove(event.getEntity().getUniqueId());pendingKills.putIfAbsent(origin.orElseThrow().source(),new PendingKill(event.getEntity().getUniqueId(),dungeonId,roomId,generation,slot));}
|
||||
@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");captureBossDefeat(attempt,event.getEntity(),room);if(room.boss().character()==null)attempt.deathObservation=nativeFacts(dungeon,room,attempt,event.getEntity(),null,true,nativeTrigger(event),"death_location");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();
|
||||
@@ -171,8 +181,9 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
.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){cleanupDungeon(dungeon.id());
|
||||
if(boss){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);
|
||||
@@ -234,7 +245,20 @@ 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 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);entity.remove();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){}}
|
||||
@@ -248,7 +272,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));}
|
||||
@@ -46,8 +48,8 @@ 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(),run.completionAudit(),run.completionPublished());});}
|
||||
@@ -62,7 +64,7 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
|
||||
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(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. */}});}
|
||||
@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);});}
|
||||
@@ -78,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);}
|
||||
|
||||
@@ -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();}
|
||||
}
|
||||
@@ -393,6 +393,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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){
|
||||
@@ -2115,6 +2117,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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));
|
||||
@@ -2136,6 +2140,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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+".");
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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.AdventureQuestState;
|
||||
import games.dmg.fantasystick.dungeon.DungeonController;
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.dungeon.DungeonState;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Villager;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Strict native dungeon ports only; real registered commands, menus, authority, combat and writer stay installed. */
|
||||
final class DungeonAuditNativeFixture implements AutoCloseable {
|
||||
final CampAuditNativeFixture shared;
|
||||
final FantasyStickLifecycleNativeTest.Fixture f;
|
||||
final FantasyStickLifecycleNativeTest.Actor actor;
|
||||
final DungeonDirectory directory;
|
||||
final DungeonController controller;
|
||||
final Map<PotionEffectType,PotionEffect> playerEffects=new HashMap<>();
|
||||
final Map<java.util.UUID,Map<PotionEffectType,PotionEffect>> nativeEffects=new HashMap<>();
|
||||
DungeonState.Dungeon definition;
|
||||
DungeonState.Room entry,boss;
|
||||
AdventureQuestState.Quest quest;
|
||||
DungeonAuditNativeFixture(Path data)throws Exception{
|
||||
shared=new CampAuditNativeFixture(data);f=shared.f;actor=shared.actor;
|
||||
directory=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");
|
||||
controller=(DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");
|
||||
effects(actor.player,playerEffects);
|
||||
shared.nativeSpawnSetup=entity->{if(!(entity instanceof Villager)){var layer=new HashMap<PotionEffectType,PotionEffect>();nativeEffects.put(entity.getUniqueId(),layer);effects(entity,layer);}};
|
||||
}
|
||||
static void effects(LivingEntity entity,Map<PotionEffectType,PotionEffect> effects){
|
||||
doAnswer(call->Set.copyOf(effects.values())).when(entity).getActivePotionEffects();
|
||||
doAnswer(call->{var value=(PotionEffect)call.getArgument(0);effects.put(value.getType(),value);return true;}).when(entity).addPotionEffect(any(PotionEffect.class),anyBoolean());
|
||||
doAnswer(call->{var value=(PotionEffect)call.getArgument(0);effects.put(value.getType(),value);return true;}).when(entity).addPotionEffect(any(PotionEffect.class));
|
||||
doAnswer(call->{effects.remove((PotionEffectType)call.getArgument(0));return null;}).when(entity).removePotionEffect(any(PotionEffectType.class));
|
||||
}
|
||||
void authorAndAccept()throws Exception{authorAndAccept("players",true);}
|
||||
void authorAndAccept(String effectTarget,boolean ordinarySlot)throws Exception{
|
||||
command("dungeon","create","--mode","individual-only","Original Dungeon");var id=directory.snapshot().dungeons().getFirst().id();
|
||||
command("dungeon","edit","start",id.toString());command("dungeon","room","create","corner","4","4","3","Original Entry");
|
||||
entry=directory.find(id).orElseThrow().rooms().getFirst();if(ordinarySlot)command("dungeon","room","mob","add","husk","Original Guard");
|
||||
command("dungeon","room","effect","add",effectTarget,"speed","2","false");
|
||||
actor.core.location.setX(17.25);command("dungeon","room","create","corner","3","4","3","Original Boss Room");
|
||||
boss=directory.find(id).orElseThrow().rooms().getLast();command("dungeon","room","boss","mob","vindicator","Original Keeper");
|
||||
command("dungeon","reward",id.toString(),"25","7");command("dungeon","edit","done");command("dungeon","enable",id.toString());command("dungeon","open",id.toString());
|
||||
definition=directory.find(id).orElseThrow();actor.core.location.setX(12.5);shared.giver();f.activate(actor);
|
||||
shared.click(actor,shared.open(actor),false);ReskillEventsNativeTest.await(f,()->shared.directory.active().size()==1);
|
||||
quest=shared.directory.active().getFirst();assertEquals(id,quest.site());assertEquals(AdventureQuestState.UnitType.SOLO,quest.unitType());
|
||||
}
|
||||
void enter(DungeonState.Room room)throws Exception{
|
||||
actor.core.location.setX(room.bounds().minX()+.5);actor.core.location.setY(room.bounds().minY()+1);actor.core.location.setZ(room.bounds().minZ()+.5);
|
||||
shared.pulse();ReskillEventsNativeTest.await(f,()->directory.runView(definition.id(),quest.catalog()).isPresent());shared.pulse();
|
||||
}
|
||||
LivingEntity nativeEntity(org.bukkit.entity.EntityType type){return shared.spawned.stream().filter(org.bukkit.entity.Entity::isValid).filter(entity->entity.getType()==type).filter(entity->controller.encounterOrigin(entity).isPresent()).findFirst().orElseThrow();}
|
||||
void command(String... args)throws Exception{DungeonAuthoringEventsNativeTest.command(f,actor,args);}
|
||||
@Override public void close()throws java.io.IOException{shared.close();}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
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 DungeonAuditReplayNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void rejectedWriterRetainsExactRegisteredNativeAndSourceClosureFactsThenReplaysOfflineWithoutRecreatingGameplayOrAdoptingCurrentContext()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");var folder=data.resolve("dungeon-audit-event-intents");java.util.List<Properties> originals;byte[] definitions,quests;games.dmg.fantasystick.dungeon.DungeonState originalState;
|
||||
try(var fixture=new DungeonAuditNativeFixture(data)){
|
||||
fixture.authorAndAccept();var f=fixture.f;var actor=fixture.actor;var before=CombatExperienceNativeTest.record(f,actor);ReskillEventsNativeTest.await(f,()->{try{return ReskillEventsNativeTest.records(data).stream().anyMatch(row->row.get("type").getAsString().endsWith("adventure.quest.accepted")&&row.getAsJsonObject("data").has("dungeon_id"));}catch(Exception failure){throw new AssertionError(failure);}});((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();fixture.enter(fixture.entry);actor.core.location.setX(77);fixture.command("dungeon","disable",fixture.definition.id().toString(),"confirm");ReskillEventsNativeTest.await(f,()->fixture.shared.directory.active().isEmpty()&&fixture.directory.snapshot().runs().isEmpty());assertEquals(before,CombatExperienceNativeTest.record(f,actor));f.plugin.setEnabled(false);f.awaitWorkers();WitchAuditTestSupport.drain(f,"dungeonOwnerDrain");originals=WitchAuditTestSupport.intents(folder);var counts=originals.stream().collect(java.util.stream.Collectors.groupingBy(value->value.getProperty("type"),java.util.stream.Collectors.counting()));
|
||||
assertEquals(Map.of("dungeon.run.started",1L,"dungeon.room.activated",1L,"dungeon.entity.spawned",1L,"dungeon.entity.removed",1L,"effect.applied",1L,"effect.ended",1L,"adventure.quest.expired",1L,"dungeon.run.closed",1L,"dungeon.disabled",1L),counts,"Every original acknowledged observation survives rejected handoff; none authorizes reward or replayed gameplay");
|
||||
originalState=new games.dmg.fantasystick.persistence.YamlDungeonRepository(data).load();definitions=Files.readAllBytes(data.resolve("dungeons.yml"));quests=Files.readAllBytes(data.resolve("adventure-quests.yml"));
|
||||
}
|
||||
try(var reopened=new DungeonAuditNativeFixture(data)){
|
||||
var f=reopened.f;var originalSessionAbsent=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(reopened.actor.core.id);assertTrue(originalSessionAbsent.isEmpty());var messages=reopened.actor.messages.size();ReskillEventsNativeTest.await(f,()->{try{var rows=ReskillEventsNativeTest.records(data);return originals.stream().allMatch(value->rows.stream().anyMatch(row->row.get("id").getAsString().equals(value.getProperty("id"))));}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
((games.dmg.fantasystick.events.DungeonAuditEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonOwnerEvents")).replay();f.plugin.setEnabled(false);f.awaitWorkers();WitchAuditTestSupport.drain(f,"dungeonOwnerDrain");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(asJsonLists(intent),matching.getFirst());}assertEquals(originalState,reopened.directory.snapshot());assertArrayEquals(definitions,Files.readAllBytes(data.resolve("dungeons.yml")));assertArrayEquals(quests,Files.readAllBytes(data.resolve("adventure-quests.yml")));assertTrue(reopened.shared.spawned.isEmpty());assertEquals(messages,reopened.actor.messages.size());
|
||||
}
|
||||
}
|
||||
private static Properties asJsonLists(Properties source){var expected=new Properties();expected.putAll(source);for(var key:source.stringPropertyNames())if(key.startsWith("data.")&&source.getProperty(key).startsWith("l")){var array=new com.google.gson.JsonArray();var contents=source.getProperty(key).substring(1);if(!contents.isEmpty())for(var value:contents.split(","))array.add(value);expected.setProperty(key,"j"+array);}return expected;}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class DungeonAuthoringEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void actualRegisteredCreateAndModeExportOnlyAcknowledgedIndependentDefinitionRevisionsAndOriginalAdministrator()throws Exception{
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;
|
||||
var directory=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"dungeon","create","--mode","individual-only","Original Dungeon"});
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Created disabled dungeon")));
|
||||
var original=directory.snapshot().dungeons().getFirst();
|
||||
assertEquals("Original Dungeon",original.name());assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,original.participationMode());
|
||||
assertFalse(original.enabled());assertNotEquals(original.id(),original.revision());assertEquals(12,original.entrance().x());assertEquals(70,original.entrance().y());assertEquals(-8,original.entrance().z());
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"dungeon","mode",original.id().toString(),"party-required"});
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Updated dungeon")));
|
||||
var changed=directory.find(original.id()).orElseThrow();assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED,changed.participationMode());assertNotEquals(original.revision(),changed.revision());assertEquals(original.entrance(),changed.entrance());assertFalse(changed.enabled());
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.created").size()==1&&rows("dungeon.mode_changed").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
var created=rows("dungeon.created");
|
||||
assertEquals(1,created.size(),"Acknowledged registered authoring must reach its own JSONL owner, not only generic admin.action");
|
||||
var row=created.getFirst();var values=row.getAsJsonObject("data");assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(original.id().toString(),values.get("dungeon_id").getAsString());assertEquals(original.revision().toString(),values.get("after_revision").getAsString());assertEquals("individual-only",values.get("after_mode").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());
|
||||
var modes=rows("dungeon.mode_changed");assertEquals(1,modes.size());var mode=modes.getFirst().getAsJsonObject("data");assertEquals(original.revision().toString(),mode.get("before_revision").getAsString());assertEquals(changed.revision().toString(),mode.get("after_revision").getAsString());assertEquals("individual-only",mode.get("before_mode").getAsString());assertEquals("party-required",mode.get("after_mode").getAsString());assertEquals("future_acceptances",mode.get("application_scope").getAsString());
|
||||
}
|
||||
}
|
||||
@Test void actualRegisteredEditorPublicationAndRemovalTraceExportsEveryAcknowledgedTypedAuthoringOutcomeWithoutRewardCopies()throws Exception{
|
||||
java.nio.file.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 directory=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");
|
||||
var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");
|
||||
command(f,actor,"dungeon","create","Authoring Trace");var id=directory.snapshot().dungeons().getFirst().id();
|
||||
command(f,actor,"dungeon","edit","start",id.toString());assertEquals(id,controller.editing(actor.core.id).orElseThrow());
|
||||
actor.core.location.setX(12.75);command(f,actor,"dungeon","entrance","set");assertEquals(12.75,directory.find(id).orElseThrow().entrance().x());
|
||||
command(f,actor,"dungeon","room","create","corner","4","4","3","Original Room");var room=directory.find(id).orElseThrow().rooms().getFirst();
|
||||
assertEquals(12,room.bounds().minX());assertEquals(69,room.bounds().minY());assertEquals(-8,room.bounds().minZ());assertEquals(15,room.bounds().maxX());assertEquals(71,room.bounds().maxY());assertEquals(-5,room.bounds().maxZ());
|
||||
command(f,actor,"dungeon","room","mob","add","husk","Original Guard");var slot=directory.room(id,room.id()).orElseThrow().mobs().getFirst();
|
||||
command(f,actor,"dungeon","room","mob","remove",slot.id().toString());assertTrue(directory.room(id,room.id()).orElseThrow().mobs().isEmpty());
|
||||
command(f,actor,"dungeon","room","effect","add","players","speed","2","false");var effect=directory.room(id,room.id()).orElseThrow().effects().getFirst();
|
||||
command(f,actor,"dungeon","room","effect","remove",effect.id().toString());assertTrue(directory.room(id,room.id()).orElseThrow().effects().isEmpty());
|
||||
command(f,actor,"dungeon","room","boss","mob","vindicator","Original Keeper");assertNotNull(directory.room(id,room.id()).orElseThrow().boss());
|
||||
command(f,actor,"dungeon","room","boss","clear");assertNull(directory.room(id,room.id()).orElseThrow().boss());
|
||||
command(f,actor,"dungeon","room","boss","mob","vindicator","Final Keeper");
|
||||
command(f,actor,"dungeon","reward",id.toString(),"1234567890","2147483647");
|
||||
command(f,actor,"dungeon","edit","done");assertTrue(controller.editing(actor.core.id).isEmpty());
|
||||
command(f,actor,"dungeon","enable",id.toString());assertTrue(directory.find(id).orElseThrow().enabled());
|
||||
command(f,actor,"dungeon","open",id.toString());assertTrue(fixture.directory.offerOpen(id));var catalog=fixture.directory.snapshot().catalog();
|
||||
command(f,actor,"dungeon","close",id.toString(),"confirm");assertFalse(fixture.directory.offerOpen(id));
|
||||
command(f,actor,"dungeon","disable",id.toString(),"confirm");assertFalse(directory.find(id).orElseThrow().enabled());
|
||||
command(f,actor,"dungeon","edit","start",id.toString());command(f,actor,"dungeon","room","remove",room.id().toString());assertTrue(directory.find(id).orElseThrow().rooms().isEmpty());
|
||||
command(f,actor,"dungeon","edit","done");command(f,actor,"dungeon","remove",id.toString(),"confirm");assertTrue(directory.find(id).isEmpty());
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.removed").size()==1&&rows("dungeon.edit.ended").size()==2;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
assertEquals(2,rows("dungeon.edit.started").size(),"Actual acknowledged editing leases need owner JSONL, not inferred definitions or particle pulses");
|
||||
var expected=java.util.Map.ofEntries(java.util.Map.entry("dungeon.created",1),java.util.Map.entry("dungeon.edit.ended",2),java.util.Map.entry("dungeon.entrance_changed",1),java.util.Map.entry("dungeon.room.created",1),java.util.Map.entry("dungeon.room.removed",1),java.util.Map.entry("dungeon.mob_slot.added",1),java.util.Map.entry("dungeon.mob_slot.removed",1),java.util.Map.entry("dungeon.effect.added",1),java.util.Map.entry("dungeon.effect.removed",1),java.util.Map.entry("dungeon.boss.configured",2),java.util.Map.entry("dungeon.boss.cleared",1),java.util.Map.entry("dungeon.enabled",1),java.util.Map.entry("dungeon.disabled",1),java.util.Map.entry("dungeon.offer.opened",1),java.util.Map.entry("dungeon.offer.closed",1),java.util.Map.entry("dungeon.removed",1));
|
||||
for(var entry:expected.entrySet()){var exported=rows(entry.getKey());assertEquals(entry.getValue().intValue(),exported.size(),entry.getKey());for(var row:exported){assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(id.toString(),row.getAsJsonObject("data").get("dungeon_id").getAsString());}}
|
||||
var roomData=rows("dungeon.room.created").getFirst().getAsJsonObject("data");assertEquals(room.id().toString(),roomData.get("room_id").getAsString());assertEquals(12,roomData.getAsJsonObject("after_bounds").get("min_x").getAsInt());assertEquals(71,roomData.getAsJsonObject("after_bounds").get("max_y").getAsInt());
|
||||
var slotData=rows("dungeon.mob_slot.added").getFirst().getAsJsonObject("data");assertEquals(slot.id().toString(),slotData.get("spawn_slot_id").getAsString());assertEquals("HUSK",slotData.get("after_entity_type").getAsString());assertEquals("Original Guard",slotData.get("after_name").getAsString());assertEquals(12.75,slotData.getAsJsonObject("after_spawn_location").get("x").getAsDouble());
|
||||
var effectData=rows("dungeon.effect.added").getFirst().getAsJsonObject("data");assertEquals(effect.id().toString(),effectData.get("effect_id").getAsString());assertEquals("minecraft:speed",effectData.get("after_effect").getAsString());assertEquals("2",effectData.get("after_level").getAsString());assertFalse(effectData.get("after_particles").getAsBoolean());
|
||||
assertEquals(catalog.revision().toString(),rows("dungeon.offer.opened").getFirst().getAsJsonObject("data").get("catalog_id").getAsString());
|
||||
var rewards=rows("dungeon.reward_changed");assertEquals(1,rewards.size(),"US-085 remains the sole completion-pool change owner");assertEquals("1234567890",rewards.getFirst().getAsJsonObject("data").get("after_xp").getAsString());assertEquals("2147483647",rewards.getFirst().getAsJsonObject("data").get("after_coins").getAsString());
|
||||
assertTrue(rows("combat.reward_resolved").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
|
||||
}
|
||||
}
|
||||
@Test void concurrentRegisteredOpensAndNoopRetriesPublishOnlyOneActualOfferTransition()throws Exception{
|
||||
java.nio.file.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 directory=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");
|
||||
command(f,actor,"dungeon","create","Concurrent Offer");var id=directory.snapshot().dungeons().getFirst().id();command(f,actor,"dungeon","edit","start",id.toString());
|
||||
command(f,actor,"dungeon","room","create","radius","2","3","Boss");command(f,actor,"dungeon","room","boss","mob","vindicator","Keeper");command(f,actor,"dungeon","reward",id.toString(),"0","0");command(f,actor,"dungeon","edit","done");command(f,actor,"dungeon","enable",id.toString());
|
||||
fixture.directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);assertFalse(fixture.directory.offerOpen(id));
|
||||
var entered=new java.util.concurrent.CountDownLatch(1);var release=new java.util.concurrent.CountDownLatch(1);
|
||||
var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(fixture.directory,"worker");
|
||||
worker.execute(()->{entered.countDown();try{if(!release.await(5,java.util.concurrent.TimeUnit.SECONDS))throw new AssertionError("Test authority barrier was not released");}catch(InterruptedException interrupted){Thread.currentThread().interrupt();throw new AssertionError(interrupted);}});
|
||||
assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));long before=actor.messages.stream().filter(value->value.startsWith("Opened dungeon")).count();
|
||||
try{for(int index=0;index<2;index++)f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"dungeon","open",id.toString()});}finally{release.countDown();}
|
||||
ReskillEventsNativeTest.await(f,()->actor.messages.stream().filter(value->value.startsWith("Opened dungeon")).count()==before+2);assertTrue(fixture.directory.offerOpen(id));
|
||||
command(f,actor,"dungeon","open",id.toString());command(f,actor,"dungeon","close",id.toString(),"confirm");command(f,actor,"dungeon","close",id.toString(),"confirm");assertFalse(fixture.directory.offerOpen(id));
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.offer.closed").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
assertEquals(1,rows("dungeon.offer.opened").size(),"Two queued requests seeing the same old closed view must not manufacture two committed open transitions");assertEquals(1,rows("dungeon.offer.closed").size());
|
||||
}
|
||||
}
|
||||
static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String... args)throws Exception{int prior=actor.messages.size();f.adminCommand.execute(actor.player,"fstickadmin",args);ReskillEventsNativeTest.await(f,()->actor.messages.size()>prior);}
|
||||
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();}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.command.ConsoleCommandSender;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class DungeonAuthoringSafetyEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void registeredQueuedLinkDistanceSettingsKeepOriginalContextAcknowledgedHistoryAndFutureDefinitionScopeWithoutRefusedOrFailedSuccess()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");var key=AdventureQuestSettings.DUNGEON_ROOM_LINK_DISTANCE;
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key});
|
||||
for(var value:List.of("20","25","30"))f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,value});actor.core.location.setX(77);ReskillEventsNativeTest.await(f,()->settings.value(key).equals("30"));
|
||||
DungeonAuthoringEventsNativeTest.command(f,actor,"config",key,"30");for(var value:List.of("0","257","1.5","-1","invalid"))f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,value});actor.allowed=false;f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,"40"});actor.allowed=true;
|
||||
var file=data.resolve("config.yml");var bytes=Files.readAllBytes(file);Files.delete(file);Files.createDirectory(file);int messages=actor.messages.size();f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,"40"});ReskillEventsNativeTest.await(f,()->actor.messages.subList(messages,actor.messages.size()).stream().anyMatch(value->value.contains("save failed")));assertEquals("30",settings.value(key));assertEquals(before,CombatExperienceNativeTest.record(f,actor));Files.delete(file);Files.write(file,bytes);
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();var changes=rows("config.changed").stream().filter(row->row.getAsJsonObject("data").has("key")&&row.getAsJsonObject("data").get("key").getAsString().equals(key)).toList();
|
||||
assertEquals(3,changes.size(),"Three actual registered link-distance publications must export their own future-definition history, never no-op/refused/failed success");assertEquals(Map.of("10","20","20","25","25","30"),changes.stream().map(row->row.getAsJsonObject("data")).collect(java.util.stream.Collectors.toMap(value->value.get("before").getAsString(),value->value.get("after").getAsString())));
|
||||
for(var row:changes){var values=row.getAsJsonObject("data");assertEquals("10",values.get("default").getAsString());assertEquals("dungeon",values.get("feature").getAsString());assertEquals("future_definitions_and_offers",values.get("application_scope").getAsString());assertEquals(12,values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());}
|
||||
}
|
||||
}
|
||||
@Test void consoleModeReadsChangesAndRegisteredDenialMalformedConcurrentFailedSaveAndReloadKeepOnlyAcknowledgedOriginalDefinitions()throws Exception{
|
||||
java.util.UUID id;games.dmg.fantasystick.dungeon.DungeonState.Dungeon finalDefinition;
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;var directory=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");DungeonAuthoringEventsNativeTest.command(f,actor,"dungeon","create","Original Console Definition");var original=directory.snapshot().dungeons().getFirst();id=original.id();
|
||||
actor.allowed=false;f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"dungeon","mode",id.toString(),"party-required"});f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"dungeon","remove",id.toString(),"confirm"});actor.allowed=true;
|
||||
for(var args:List.of(new String[]{"dungeon","mode",id.toString(),"unknown"},new String[]{"dungeon","mode","bad","party-required"},new String[]{"dungeon","create","--mode","unknown","No Definition"},new String[]{"dungeon","room","create","corner","0","4","3"}))f.adminCommand.execute(actor.player,"fstickadmin",args);
|
||||
assertEquals(List.of(original),directory.snapshot().dungeons());var entered=new CountDownLatch(1);var release=new CountDownLatch(1);var worker=(ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(directory,"worker");worker.execute(()->{entered.countDown();try{if(!release.await(5,TimeUnit.SECONDS))throw new AssertionError("Unreleased test authority barrier");}catch(InterruptedException interrupted){Thread.currentThread().interrupt();throw new AssertionError(interrupted);}});assertTrue(entered.await(5,TimeUnit.SECONDS));int prior=actor.messages.size();try{for(int n=0;n<2;n++)f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"dungeon","mode",id.toString(),"individual-only"});}finally{release.countDown();}ReskillEventsNativeTest.await(f,()->actor.messages.size()>=prior+2);var solo=directory.find(id).orElseThrow();assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,solo.participationMode());assertNotEquals(original.revision(),solo.revision());
|
||||
var file=data.resolve("dungeons.yml");var bytes=Files.readAllBytes(file);Files.delete(file);Files.createDirectory(file);DungeonAuthoringEventsNativeTest.command(f,actor,"dungeon","mode",id.toString(),"party-required");assertEquals(solo,directory.find(id).orElseThrow());Files.delete(file);Files.write(file,bytes);
|
||||
var console=mock(ConsoleCommandSender.class);when(console.hasPermission("fantasystick.admin")).thenReturn(true);when(console.getName()).thenReturn("Original console administrator");f.adminCommand.execute(console,"fstickadmin",new String[]{"dungeon","mode",id.toString()});f.adminCommand.execute(console,"fstickadmin",new String[]{"dungeon","list"});f.adminCommand.execute(console,"fstickadmin",new String[]{"dungeon","create","No Console Definition"});f.adminCommand.execute(console,"fstickadmin",new String[]{"dungeon","mode",id.toString(),"party-required"});ReskillEventsNativeTest.await(f,()->directory.find(id).orElseThrow().participationMode()==AdventureQuestState.ParticipationMode.PARTY_REQUIRED);finalDefinition=directory.find(id).orElseThrow();assertEquals(1,directory.snapshot().dungeons().size());assertEquals(original.entrance(),finalDefinition.entrance());
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.mode_changed").size()==2;}catch(Exception failure){throw new AssertionError(failure);}});f.plugin.setEnabled(false);f.awaitWorkers();assertEquals(1,rows("dungeon.created").size());assertTrue(rows("dungeon.removed").isEmpty());var modes=rows("dungeon.mode_changed");assertEquals(2,modes.size());var consoleChange=modes.stream().filter(row->row.getAsJsonObject("data").get("actor_kind").getAsString().equals("console")).findFirst().orElseThrow().getAsJsonObject("data");assertEquals("not_applicable",consoleChange.get("location_status").getAsString());assertEquals("Original console administrator",consoleChange.has("administrator_name")?consoleChange.get("administrator_name").getAsString():null,"Console mutation must retain its original administrator identity, not only console kind");assertEquals(solo.revision().toString(),consoleChange.get("before_revision").getAsString());assertEquals(finalDefinition.revision().toString(),consoleChange.get("after_revision").getAsString());
|
||||
}
|
||||
var source=Files.readAllBytes(data.resolve("dungeons.yml"));try(var reopened=new CampAuditNativeFixture(data)){var directory=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(reopened.f.plugin,"dungeons");assertEquals(finalDefinition,directory.find(id).orElseThrow());assertArrayEquals(source,Files.readAllBytes(data.resolve("dungeons.yml")));reopened.f.plugin.setEnabled(false);reopened.f.awaitWorkers();assertEquals(2,rows("dungeon.mode_changed").size());}
|
||||
}
|
||||
@Test void acknowledgedRegisteredEditLeaseAuditsOriginalActionAndSavedViewEvenWhenAdministratorCannotReceiveItsDeferredReply()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new DungeonAuditNativeFixture(data)){
|
||||
fixture.authorAndAccept();var f=fixture.f;var actor=fixture.actor;var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");var original=actor.core.location.clone();f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"dungeon","edit","start",fixture.definition.id().toString(),"confirm"});doReturn(false).when(actor.player).hasPermission("fantasystick.admin");doReturn(false).when(actor.player).isOnline();actor.core.location.setX(77);ReskillEventsNativeTest.await(f,()->controller.editing(actor.core.id).isPresent());var leased=fixture.directory.find(fixture.definition.id()).orElseThrow();assertFalse(leased.enabled());ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.edit.started").size()==2;}catch(Exception failure){throw new AssertionError(failure);}});f.plugin.setEnabled(false);f.awaitWorkers();WitchAuditTestSupport.drain(f,"dungeonOwnerDrain");var started=rows("dungeon.edit.started");assertEquals(2,started.size(),"An actual acquired lease must remain observable independently of deferred reply permission/online status");var originalStart=started.stream().map(row->row.getAsJsonObject("data")).filter(values->values.get("definition_revision").getAsString().equals(leased.revision().toString())).findFirst().orElseThrow();assertEquals(actor.core.id.toString(),originalStart.get("player_id").getAsString());assertEquals(original.getX(),originalStart.getAsJsonObject("location").get("x").getAsDouble());assertEquals("acknowledged_lease_view",originalStart.get("definition_capture_quality").getAsString());assertTrue(controller.editing(actor.core.id).isEmpty());
|
||||
}
|
||||
}
|
||||
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 java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.bukkit.entity.EntityType;
|
||||
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 DungeonExpiryClosureEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@ParameterizedTest @ValueSource(strings={"disable","edit","reset","close"})
|
||||
void registeredNonCompletionTransitionsExpireOnlyOriginalDungeonAuthorityAndCloseOneDurableRunWithoutRewards(String transition)throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new DungeonAuditNativeFixture(data)){
|
||||
fixture.authorAndAccept();fixture.enter(fixture.entry);var guard=fixture.nativeEntity(EntityType.HUSK);var f=fixture.f;var actor=fixture.actor;var character=CombatExperienceNativeTest.record(f,actor);var quest=fixture.quest;var oldCatalog=fixture.shared.directory.snapshot().catalog();var definition=fixture.definition;
|
||||
switch(transition){case "disable"->fixture.command("dungeon","disable",definition.id().toString(),"confirm");case "edit"->fixture.command("dungeon","edit","start",definition.id().toString(),"confirm");case "reset"->{f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure","reroll","confirm"});ReskillEventsNativeTest.await(f,()->fixture.shared.directory.snapshot().reset()==null&&!oldCatalog.revision().equals(fixture.shared.directory.snapshot().catalog().revision()));}case "close"->fixture.command("dungeon","close",definition.id().toString(),"confirm");default->throw new AssertionError(transition);}
|
||||
ReskillEventsNativeTest.await(f,()->fixture.shared.directory.active().isEmpty()&&fixture.directory.runView(definition.id(),quest.catalog()).isEmpty());assertFalse(guard.isValid());assertFalse(fixture.playerEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));assertEquals(character,CombatExperienceNativeTest.record(f,actor));assertEquals(definition.rooms(),fixture.directory.find(definition.id()).orElseThrow().rooms());assertEquals(definition.completionReward(),fixture.directory.find(definition.id()).orElseThrow().completionReward());
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.quest.expired").stream().filter(row->row.getAsJsonObject("data").has("dungeon_id")).count()==1&&rows("dungeon.run.closed").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();var expired=rows("adventure.quest.expired").stream().filter(row->row.getAsJsonObject("data").has("dungeon_id")).toList();assertEquals(1,expired.size(),"The acknowledged "+transition+" must export exactly one original dungeon expiry, not a camp/champion event or inferred completion");var values=expired.getFirst().getAsJsonObject("data");assertEquals(quest.id().toString(),values.get("quest_id").getAsString());assertEquals(quest.catalog().toString(),values.get("generation_id").getAsString());assertEquals(quest.site().toString(),values.get("dungeon_id").getAsString());assertEquals(quest.unit().toString(),values.get("unit_id").getAsString());assertEquals("individual-only",values.get("accepted_mode").getAsString());assertEquals(quest.expiresAt().toString(),values.get("expires_at").getAsString());
|
||||
var closed=rows("dungeon.run.closed");assertEquals(1,closed.size(),"Only the one actually removed durable dungeon run owns non-completion closure");var outcome=closed.getFirst().getAsJsonObject("data");assertEquals(quest.catalog().toString(),outcome.get("generation_id").getAsString());assertEquals("non_reward_source_cleanup",outcome.get("reward_status").getAsString());assertFalse(outcome.get("run_defeated").getAsBoolean());assertTrue(rows("dungeon.entity.defeated").isEmpty());assertTrue(rows("dungeon.completed").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());assertTrue(rows("combat.reward_resolved").isEmpty());
|
||||
}
|
||||
}
|
||||
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();}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
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 DungeonNativeEffectsSafetyEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void realBothTargetRoomEffectsAuditOneNativeMobLayerWhileStrongerPlayerAndUnrelatedStateSurvivePulseAndCleanup()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new DungeonAuditNativeFixture(data)){
|
||||
fixture.authorAndAccept("both",true);var stronger=new PotionEffect(PotionEffectType.SPEED,800,4,false,false);var unrelated=new PotionEffect(PotionEffectType.NIGHT_VISION,900,0,false,false);fixture.playerEffects.put(PotionEffectType.SPEED,stronger);fixture.playerEffects.put(PotionEffectType.NIGHT_VISION,unrelated);var before=CombatExperienceNativeTest.record(fixture.f,fixture.actor);
|
||||
fixture.enter(fixture.entry);var guard=fixture.nativeEntity(EntityType.HUSK);assertEquals(1,fixture.nativeEffects.get(guard.getUniqueId()).get(PotionEffectType.SPEED).getAmplifier());for(int n=0;n<15;n++)fixture.shared.pulse();assertEquals(stronger,fixture.playerEffects.get(PotionEffectType.SPEED));assertEquals(unrelated,fixture.playerEffects.get(PotionEffectType.NIGHT_VISION));
|
||||
fixture.actor.core.location.setX(70);fixture.shared.pulse();assertFalse(guard.isValid());assertEquals(stronger,fixture.playerEffects.get(PotionEffectType.SPEED));assertEquals(unrelated,fixture.playerEffects.get(PotionEffectType.NIGHT_VISION));assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));
|
||||
ReskillEventsNativeTest.await(fixture.f,()->{try{return rows("dungeon.entity.removed").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});fixture.f.plugin.setEnabled(false);fixture.f.awaitWorkers();var applications=rows("effect.applied").stream().filter(row->row.getAsJsonObject("data").has("dungeon_id")).toList();assertEquals(1,applications.size(),"The actual configured native mob layer needs one effect owner record, never a pulse or stronger-player overwrite");var values=applications.getFirst().getAsJsonObject("data");assertEquals("managed_mob",values.get("target_kind").getAsString());assertEquals(guard.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals("minecraft:speed",values.get("effect").getAsString());assertEquals("2",values.get("level").getAsString());assertFalse(values.get("particles").getAsBoolean());assertEquals(1,rows("effect.ended").stream().filter(row->row.getAsJsonObject("data").has("dungeon_id")).count());assertTrue(rows("combat.reward_resolved").isEmpty());
|
||||
}
|
||||
}
|
||||
@Test void returnedConfiguredButCancelledNativeAdditionNeverProvesSpawnOrMobEffectMaterializationAndCannotChangeGameplayAuthority()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new DungeonAuditNativeFixture(data)){
|
||||
fixture.authorAndAccept("mobs",true);fixture.shared.nativeSpawnOutcome=entity->{if(entity.getType()==EntityType.HUSK)when(entity.isValid()).thenReturn(false);};var before=CombatExperienceNativeTest.record(fixture.f,fixture.actor);fixture.enter(fixture.entry);assertTrue(fixture.shared.spawned.stream().anyMatch(entity->entity.getType()==EntityType.HUSK&&!entity.isValid()));assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));assertTrue(fixture.directory.runView(fixture.definition.id(),fixture.quest.catalog()).orElseThrow().defeatedSlots().isEmpty());assertEquals(fixture.quest,fixture.shared.directory.findQuest(fixture.quest.id()).orElseThrow());fixture.f.plugin.setEnabled(false);fixture.f.awaitWorkers();assertTrue(rows("dungeon.entity.spawned").isEmpty());assertTrue(rows("effect.applied").stream().noneMatch(row->row.getAsJsonObject("data").has("dungeon_id")));assertTrue(rows("dungeon.entity.defeated").isEmpty());assertTrue(rows("dungeon.completed").isEmpty());assertTrue(rows("combat.reward_resolved").isEmpty());
|
||||
}
|
||||
}
|
||||
@Test void actualEmptyRoomRecordsOnlyDurableProgressAndRepresentationWithoutNativeKillsRoomRewardsOrTrophies()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new DungeonAuditNativeFixture(data)){
|
||||
fixture.authorAndAccept("players",false);var before=CombatExperienceNativeTest.record(fixture.f,fixture.actor);fixture.enter(fixture.entry);ReskillEventsNativeTest.await(fixture.f,()->fixture.directory.runView(fixture.definition.id(),fixture.quest.catalog()).map(run->run.clearedRooms().contains(fixture.entry.id())).orElse(false));assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));assertEquals(fixture.quest,fixture.shared.directory.findQuest(fixture.quest.id()).orElseThrow());ReskillEventsNativeTest.await(fixture.f,()->{try{return rows("dungeon.room.cleared").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});fixture.f.plugin.setEnabled(false);fixture.f.awaitWorkers();var values=rows("dungeon.room.cleared").getFirst().getAsJsonObject("data");assertEquals("no_room_reward",values.get("reward_status").getAsString());assertEquals(fixture.quest.id().toString(),values.getAsJsonArray("represented_quest_ids").get(0).getAsString());assertTrue(rows("dungeon.entity.spawned").isEmpty());assertTrue(rows("dungeon.entity.defeated").isEmpty());assertTrue(rows("dungeon.completed").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());assertTrue(rows("combat.reward_resolved").isEmpty());
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
|
||||
void unavailablePostApplicationMetadataAndOptionalIntentStorageCannotInterruptRealNativeRoomAuthority(boolean unavailableStorage)throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");if(unavailableStorage)Files.writeString(data.resolve("dungeon-audit-event-intents"),"test-only optional observation storage unavailable");
|
||||
try(var fixture=new DungeonAuditNativeFixture(data)){
|
||||
fixture.authorAndAccept();var failed=new java.util.concurrent.atomic.AtomicBoolean();doAnswer(call->{if(fixture.playerEffects.containsKey(PotionEffectType.SPEED)&&failed.compareAndSet(false,true))throw new IllegalStateException("test-only post-application observation unavailable");return new java.util.ArrayList<>(fixture.playerEffects.values());}).when(fixture.actor.player).getActivePotionEffects();var before=CombatExperienceNativeTest.record(fixture.f,fixture.actor);assertDoesNotThrow(()->fixture.enter(fixture.entry),"A failed optional post-application metadata read cannot interrupt the registered runtime after native application");assertTrue(failed.get());var guard=fixture.nativeEntity(EntityType.HUSK);assertEquals(1,fixture.playerEffects.get(PotionEffectType.SPEED).getAmplifier());assertEquals(fixture.quest,fixture.shared.directory.findQuest(fixture.quest.id()).orElseThrow());assertTrue(fixture.directory.runView(fixture.definition.id(),fixture.quest.catalog()).orElseThrow().defeatedSlots().isEmpty());assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));fixture.actor.core.location.setX(70);fixture.shared.pulse();assertFalse(guard.isValid());assertFalse(fixture.playerEffects.containsKey(PotionEffectType.SPEED));fixture.f.plugin.setEnabled(false);fixture.f.awaitWorkers();WitchAuditTestSupport.drain(fixture.f,"dungeonOwnerDrain");assertTrue(rows("dungeon.completed").isEmpty());assertTrue(rows("combat.reward_resolved").isEmpty());if(unavailableStorage)assertTrue(rows("dungeon.entity.spawned").isEmpty());
|
||||
}
|
||||
}
|
||||
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();}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class DungeonRuntimeBoundaryEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void originalAcceptedModeAndGiverAbandonmentStayIsolatedFromLaterModeChangesAndCannotEmitExpiryOrRewardTwice()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new DungeonAuditNativeFixture(data)){
|
||||
fixture.authorAndAccept();fixture.enter(fixture.entry);var before=CombatExperienceNativeTest.record(fixture.f,fixture.actor);fixture.command("dungeon","mode",fixture.definition.id().toString(),"party-required");assertEquals(fixture.quest,fixture.shared.directory.findQuest(fixture.quest.id()).orElseThrow());var giver=fixture.shared.directory.snapshot().givers().getFirst();var menu=fixture.shared.open(fixture.actor);fixture.shared.click(fixture.actor,menu,true);ReskillEventsNativeTest.await(fixture.f,()->fixture.shared.directory.active().isEmpty());fixture.shared.click(fixture.actor,menu,true);fixture.shared.pulse();assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));ReskillEventsNativeTest.await(fixture.f,()->{try{return rows("adventure.quest.abandoned").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});fixture.f.plugin.setEnabled(false);fixture.f.awaitWorkers();var values=rows("adventure.quest.abandoned").getFirst().getAsJsonObject("data");assertEquals(fixture.quest.id().toString(),values.get("quest_id").getAsString());assertEquals("individual-only",values.get("accepted_mode").getAsString());assertEquals(giver.id().toString(),values.get("giver_id").getAsString());assertEquals("original_menu_target",values.get("giver_capture_quality").getAsString());assertEquals(1,rows("adventure.quest.accepted").size());assertTrue(rows("adventure.quest.expired").isEmpty());assertTrue(rows("combat.reward_resolved").isEmpty());
|
||||
}
|
||||
}
|
||||
@Test void actualChunkUnloadSurvivorCleanupCapturesExactCauseWithoutClearingOriginalQuestRunOrCreatingDefeat()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new DungeonAuditNativeFixture(data)){
|
||||
fixture.authorAndAccept();fixture.enter(fixture.entry);var before=CombatExperienceNativeTest.record(fixture.f,fixture.actor);var run=fixture.directory.runView(fixture.definition.id(),fixture.quest.catalog()).orElseThrow();var guard=fixture.nativeEntity(EntityType.HUSK);var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(fixture.actor.world);when(chunk.getX()).thenReturn(0);when(chunk.getZ()).thenReturn(-1);when(chunk.getEntities()).thenReturn(new Entity[]{guard});fixture.f.dispatcher.accept(new org.bukkit.event.world.ChunkUnloadEvent(chunk));assertFalse(guard.isValid());assertEquals(run,fixture.directory.runView(fixture.definition.id(),fixture.quest.catalog()).orElseThrow());assertEquals(fixture.quest,fixture.shared.directory.findQuest(fixture.quest.id()).orElseThrow());assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));ReskillEventsNativeTest.await(fixture.f,()->{try{return rows("dungeon.entity.removed").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});fixture.f.plugin.setEnabled(false);fixture.f.awaitWorkers();assertEquals("chunk_unload",rows("dungeon.entity.removed").getFirst().getAsJsonObject("data").get("reason").getAsString(),"Registered native chunk cleanup must preserve its original cause, not silently become generic runtime cleanup");assertTrue(rows("dungeon.entity.defeated").isEmpty());assertTrue(rows("dungeon.run.closed").isEmpty());assertTrue(rows("combat.reward_resolved").isEmpty());
|
||||
}
|
||||
}
|
||||
@Test void failedInitialDisableSaveKeepsRealNativeQuestRunAndOwnedEffectsThenRegisteredRetryExportsOneActualCommittedTransition()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new DungeonAuditNativeFixture(data)){
|
||||
fixture.authorAndAccept();fixture.enter(fixture.entry);var before=CombatExperienceNativeTest.record(fixture.f,fixture.actor);var state=fixture.directory.snapshot();var guard=fixture.nativeEntity(EntityType.HUSK);var file=data.resolve("dungeons.yml");var bytes=Files.readAllBytes(file);Files.delete(file);Files.createDirectory(file);Files.writeString(file.resolve("held"),"test-only failed source save");fixture.command("dungeon","disable",fixture.definition.id().toString(),"confirm");assertEquals(state,fixture.directory.snapshot());assertEquals(fixture.quest,fixture.shared.directory.findQuest(fixture.quest.id()).orElseThrow());assertTrue(guard.isValid());assertTrue(fixture.playerEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));assertTrue(rows("dungeon.disabled").isEmpty());assertTrue(rows("dungeon.run.closed").isEmpty());assertTrue(rows("adventure.quest.expired").isEmpty());Files.delete(file.resolve("held"));Files.delete(file);Files.write(file,bytes);fixture.command("dungeon","disable",fixture.definition.id().toString(),"confirm");ReskillEventsNativeTest.await(fixture.f,()->{try{return rows("dungeon.disabled").size()==1&&rows("dungeon.run.closed").size()==1&&rows("adventure.quest.expired").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});assertFalse(guard.isValid());assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));fixture.f.plugin.setEnabled(false);fixture.f.awaitWorkers();assertEquals(1,rows("dungeon.disabled").size());assertEquals(1,rows("dungeon.run.closed").size());assertEquals(1,rows("adventure.quest.expired").size());assertTrue(rows("combat.reward_resolved").isEmpty());
|
||||
}
|
||||
}
|
||||
@Test void acknowledgedDefinitionDisableRemainsTruthfulWhenLaterAdventureSaveFailsAndRetryCannotManufactureASecondDefinitionTransition()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new DungeonAuditNativeFixture(data)){
|
||||
fixture.authorAndAccept();fixture.enter(fixture.entry);var before=CombatExperienceNativeTest.record(fixture.f,fixture.actor);var guard=fixture.nativeEntity(EntityType.HUSK);var file=data.resolve("adventure-quests.yml");var bytes=Files.readAllBytes(file);Files.delete(file);Files.createDirectory(file);Files.writeString(file.resolve("held"),"test-only failed later source save");fixture.command("dungeon","disable",fixture.definition.id().toString(),"confirm");assertFalse(fixture.directory.find(fixture.definition.id()).orElseThrow().enabled());assertTrue(fixture.directory.snapshot().runs().isEmpty());assertEquals(fixture.quest,fixture.shared.directory.findQuest(fixture.quest.id()).orElseThrow());assertTrue(guard.isValid());assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));ReskillEventsNativeTest.await(fixture.f,()->{try{return rows("dungeon.run.closed").size()==1&&rows("dungeon.disabled").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});assertEquals(1,rows("dungeon.disabled").size(),"The actual saved definition transition must not disappear merely because a later, independent adventure save failed");assertEquals("definition_save_only",rows("dungeon.disabled").getFirst().getAsJsonObject("data").get("cleanup_status").getAsString());assertTrue(rows("adventure.quest.expired").isEmpty());Files.delete(file.resolve("held"));Files.delete(file);Files.write(file,bytes);fixture.command("dungeon","disable",fixture.definition.id().toString(),"confirm");ReskillEventsNativeTest.await(fixture.f,()->{try{return rows("adventure.quest.expired").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});assertFalse(guard.isValid());assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));fixture.f.plugin.setEnabled(false);fixture.f.awaitWorkers();assertEquals(1,rows("dungeon.disabled").size());assertEquals(1,rows("dungeon.run.closed").size());assertEquals(1,rows("adventure.quest.expired").size());
|
||||
}
|
||||
}
|
||||
@Test void registeredPersistentPartyRoomActivationCarriesItsOriginalAcceptedRosterRatherThanCurrentMembershipOrProgressionFeedbackPreference()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new DungeonAuditNativeFixture(data)){
|
||||
fixture.authorAndAccept();var leader=fixture.actor;fixture.shared.click(leader,fixture.shared.open(leader),true);ReskillEventsNativeTest.await(fixture.f,()->fixture.shared.directory.active().isEmpty());var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(leader.world);fixture.f.players.put(member.core.id,member.player);fixture.f.activate(member);DungeonAuditNativeFixture.effects(member.player,new java.util.HashMap<>());var parties=PartyLifecycleNativeTest.parties(fixture.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.command("dungeon","mode",fixture.definition.id().toString(),"party-available");fixture.shared.click(leader,fixture.shared.open(leader),true);ReskillEventsNativeTest.await(fixture.f,()->fixture.shared.directory.active().size()==1);fixture.quest=fixture.shared.directory.active().getFirst();assertEquals(java.util.Set.of(leader.core.record.id(),member.core.record.id()),fixture.quest.snapshot());var added=new FantasyStickLifecycleNativeTest.Actor(data);added.core.location.setWorld(leader.world);fixture.f.players.put(added.core.id,added.player);fixture.f.activate(added);assertTrue(parties.accept(added.core.id,parties.invite(leader.core.id,party.id(),added.core.id).orElseThrow().id()));assertFalse(fixture.quest.snapshot().contains(added.core.record.id()));assertTrue(fixture.f.playerCommand.execute(leader.player,"fstick",new String[]{"feedback","off"}));ReskillEventsNativeTest.await(fixture.f,()->leader.messages.contains("Fantasy feedback is now off."));var before=CombatExperienceNativeTest.record(fixture.f,leader);fixture.enter(fixture.entry);var guard=fixture.nativeEntity(EntityType.HUSK);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(added.player);when(source.getDirectEntity()).thenReturn(added.player);var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(added.player,guard,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);fixture.f.dispatcher.accept(hit);assertTrue(hit.isCancelled());assertEquals(before,CombatExperienceNativeTest.record(fixture.f,leader));ReskillEventsNativeTest.await(fixture.f,()->{try{return rows("dungeon.room.activated").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});fixture.f.plugin.setEnabled(false);fixture.f.awaitWorkers();var values=rows("dungeon.room.activated").getFirst().getAsJsonObject("data");assertEquals(fixture.quest.id().toString(),values.get("quest_id").getAsString());assertEquals(party.id().toString(),values.get("unit_id").getAsString());assertTrue(values.has("accepted_roster"),"Actual activation must retain original accepted party evidence even after unrelated membership changes");assertEquals(fixture.quest.snapshot().stream().map(java.util.UUID::toString).collect(java.util.stream.Collectors.toSet()),java.util.stream.StreamSupport.stream(values.getAsJsonArray("accepted_roster").spliterator(),false).map(com.google.gson.JsonElement::getAsString).collect(java.util.stream.Collectors.toSet()));assertTrue(rows("combat.reward_resolved").isEmpty());
|
||||
}
|
||||
}
|
||||
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,71 @@
|
||||
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.dungeon.DungeonState;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
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 DungeonRuntimeEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void actualRegisteredGiverAcceptanceNativeSlotSettlementEffectsAndBossCompletionExportOneOriginalRuntimeJourneyWithoutRewardCopies()throws Exception{
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\nhostile-coin-drop-min: 2\nhostile-coin-drop-max: 2\n");
|
||||
try(var fixture=new DungeonAuditNativeFixture(data)){
|
||||
fixture.authorAndAccept();var f=fixture.f;var actor=fixture.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.enter(fixture.entry);var guard=fixture.nativeEntity(EntityType.HUSK);var slot=fixture.directory.room(fixture.definition.id(),fixture.entry.id()).orElseThrow().mobs().getFirst();
|
||||
assertEquals(slot.id().toString(),fixture.controller.killEvidence(guard).get("spawn_slot_or_boss"));assertEquals(1,fixture.playerEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
var source=mock(DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
|
||||
var cancelled=new EntityDamageByEntityEvent(actor.player,guard,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
|
||||
var damage=new EntityDamageByEntityEvent(actor.player,guard,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(damage);assertFalse(damage.isCancelled());
|
||||
var loot=new ItemStack(Material.ROTTEN_FLESH);var death=new EntityDeathEvent(guard,source,new ArrayList<>(List.of(loot)),5);f.dispatcher.accept(death);f.dispatcher.accept(death);
|
||||
ReskillEventsNativeTest.await(f,()->fixture.directory.runView(fixture.definition.id(),fixture.quest.catalog()).map(run->run.defeatedSlots().contains(slot.id())&&run.clearedRooms().contains(fixture.entry.id())).orElse(false));
|
||||
assertEquals(List.of(loot),death.getDrops());assertEquals(5,death.getDroppedExp());var afterGuard=CombatExperienceNativeTest.record(f,actor);assertTrue(afterGuard.experience().compareTo(before.experience())>0);assertEquals(before.coins().add(java.math.BigInteger.TWO),afterGuard.coins());
|
||||
fixture.enter(fixture.boss);assertFalse(fixture.playerEffects.containsKey(PotionEffectType.SPEED));var keeper=fixture.nativeEntity(EntityType.VINDICATOR);
|
||||
var strike=new EntityDamageByEntityEvent(actor.player,keeper,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(strike);assertFalse(strike.isCancelled());
|
||||
var bossDeath=new EntityDeathEvent(keeper,source,new ArrayList<>(),0);f.dispatcher.accept(bossDeath);f.dispatcher.accept(bossDeath);
|
||||
var trophies=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
|
||||
ReskillEventsNativeTest.await(f,()->fixture.directory.runView(fixture.definition.id(),fixture.quest.catalog()).map(DungeonState.Run::defeated).orElse(false)&&fixture.shared.directory.active().isEmpty()&&trophies.trophies(before.id()).size()==1);
|
||||
assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(25),java.math.BigInteger.valueOf(7)),trophies.trophies(before.id()).getFirst().total());
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.completed").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
var accepted=rows("adventure.quest.accepted").stream().filter(row->row.getAsJsonObject("data").has("dungeon_id")).toList();
|
||||
assertEquals(1,accepted.size(),"The actual registered dungeon Quest Giver acceptance needs its independent owner JSONL, not a fishing/camp copy");
|
||||
var acceptedData=accepted.getFirst().getAsJsonObject("data");assertEquals(fixture.quest.id().toString(),acceptedData.get("quest_id").getAsString());assertEquals(fixture.definition.id().toString(),acceptedData.get("dungeon_id").getAsString());assertEquals(session.toString(),acceptedData.get("session_id").getAsString());assertEquals("original_menu_target",acceptedData.get("giver_capture_quality").getAsString());
|
||||
assertEquals(1,rows("dungeon.run.started").size());assertEquals(2,rows("dungeon.room.activated").size());assertEquals(2,rows("dungeon.entity.spawned").size());assertEquals(2,rows("dungeon.entity.defeated").size());assertEquals(2,rows("dungeon.room.cleared").size());assertEquals(1,rows("dungeon.run.closed").size());assertEquals(1,rows("dungeon.completed").size());
|
||||
for(var cleared:rows("dungeon.room.cleared")){var facts=cleared.getAsJsonObject("data");assertTrue(facts.has("represented_units"),"Actual durable room progress needs original represented unit identities and frozen authority, not only quest UUIDs");var units=facts.getAsJsonArray("represented_units");assertEquals(1,units.size());var unit=units.get(0).getAsJsonObject();assertEquals(fixture.quest.id().toString(),unit.get("quest_id").getAsString());assertEquals(fixture.quest.unit().toString(),unit.get("unit_id").getAsString());assertEquals("solo",unit.get("unit_kind").getAsString());assertEquals("individual-only",unit.get("accepted_mode").getAsString());assertEquals(1,unit.getAsJsonArray("accepted_roster").size());assertEquals(fixture.quest.snapshot().iterator().next().toString(),unit.getAsJsonArray("accepted_roster").get(0).getAsString());assertFalse(unit.has("xp"));assertFalse(unit.has("coins"));}
|
||||
var defeated=rows("dungeon.entity.defeated");assertTrue(defeated.stream().allMatch(row->row.getAsJsonObject("data").get("generation_id").getAsString().equals(fixture.quest.catalog().toString())));assertTrue(defeated.stream().anyMatch(row->row.getAsJsonObject("data").get("native_entity_id").getAsString().equals(guard.getUniqueId().toString())&&row.getAsJsonObject("data").get("spawn_slot_id").getAsString().equals(slot.id().toString())));
|
||||
assertEquals(1,rows("effect.applied").stream().filter(row->row.getAsJsonObject("data").has("dungeon_id")).count());assertEquals(1,rows("effect.ended").stream().filter(row->row.getAsJsonObject("data").has("dungeon_id")).count());
|
||||
assertEquals(1,rows("adventure.trophy_earned").stream().filter(row->row.getAsJsonObject("data").get("trophy_type").getAsString().equals("dungeon_trophy")).count());
|
||||
}
|
||||
}
|
||||
@Test void originalActivationOwnsNativeSpawnContextAndLeavingDespawnsOnlySurvivorsWithoutRewardsOrRepeatedPulseEvents()throws Exception{
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new DungeonAuditNativeFixture(data)){
|
||||
fixture.authorAndAccept();var f=fixture.f;var actor=fixture.actor;var before=CombatExperienceNativeTest.record(f,actor);fixture.enter(fixture.entry);var guard=fixture.nativeEntity(EntityType.HUSK);
|
||||
var originalLocation=guard.getLocation().clone();var session=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).orElseThrow().session();
|
||||
for(int n=0;n<12;n++)fixture.shared.pulse();ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.entity.spawned").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
var spawn=rows("dungeon.entity.spawned").getFirst();var details=spawn.getAsJsonObject("data");assertEquals(actor.core.id.toString(),details.get("triggering_player_id")==null?null:details.get("triggering_player_id").getAsString(),"Native spawn must retain the original eligible activation, not an unavailable or later player");assertEquals(session.toString(),details.get("session_id").getAsString());assertEquals(fixture.quest.id().toString(),details.get("quest_id").getAsString());assertEquals(1,rows("dungeon.room.activated").size());
|
||||
actor.core.location.setX(70);fixture.shared.pulse();assertFalse(guard.isValid());assertFalse(fixture.playerEffects.containsKey(PotionEffectType.SPEED));assertEquals(before,CombatExperienceNativeTest.record(f,actor));assertTrue(fixture.directory.runView(fixture.definition.id(),fixture.quest.catalog()).orElseThrow().defeatedSlots().isEmpty());
|
||||
ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.entity.removed").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});var removed=rows("dungeon.entity.removed").getFirst().getAsJsonObject("data");assertEquals("no_eligible_player_in_room",removed.get("reason").getAsString());assertEquals("non_reward_cleanup",removed.get("reward_status").getAsString());assertEquals(originalLocation.getX(),removed.getAsJsonObject("removal_location").get("x").getAsDouble());assertEquals(guard.getUniqueId().toString(),removed.get("native_entity_id").getAsString());
|
||||
actor.core.location.setX(12.5);fixture.enter(fixture.entry);var replacement=fixture.nativeEntity(EntityType.HUSK);assertNotEquals(guard.getUniqueId(),replacement.getUniqueId());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();assertTrue(rows("dungeon.entity.defeated").isEmpty());assertTrue(rows("dungeon.completed").isEmpty());assertTrue(rows("combat.reward_resolved").isEmpty());
|
||||
}
|
||||
}
|
||||
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,36 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.dungeon.DungeonState;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class DungeonAuditEventBridgeTest {
|
||||
@TempDir Path data;
|
||||
@Test void rejectedOriginalRoomMutationReplaysExactEventIdentityTimeContextAndTypedPayloadWithoutDefinitionAuthority()throws Exception{
|
||||
UUID id=UUID.randomUUID(),world=UUID.randomUUID(),operation=UUID.randomUUID();
|
||||
var before=new DungeonState.Dungeon(id,"Original",world,new DungeonState.Position(12.75,70,-8,45,10),false,UUID.randomUUID(),List.of());
|
||||
var room=new DungeonState.Room(UUID.randomUUID(),"Original Room",new DungeonState.Bounds(12,69,-8,15,71,-5),List.of(),List.of(),DungeonState.Reward.empty(),null);
|
||||
var after=new DungeonState.Dungeon(id,"Original",world,before.entrance(),false,UUID.randomUUID(),List.of(room));
|
||||
var context=new EventInput.Context(UUID.randomUUID(),"Original administrator",null,null,null,new EventInput.Location(world,"Original realm",12.75,70,-8,Instant.now(),"native"));
|
||||
var actor=new CampEventBridge.Actor(context,Map.of("actor_kind","player","actor_location",Map.of("world_id",world.toString(),"x",12.75,"y",70.0,"z",-8.0)));
|
||||
var rejected=new java.util.concurrent.CopyOnWriteArrayList<EventInput>();var failures=new java.util.concurrent.CopyOnWriteArrayList<Throwable>();
|
||||
var first=new DungeonAuditEventBridge(event->{rejected.add(event);return CompletableFuture.completedFuture(LocalEventLog.Delivery.REJECTED);},data,ignored->actor,failures::add);
|
||||
first.definitionSaved(operation,actor,new DungeonDirectory.Publication(new DungeonState(List.of(before),List.of()),new DungeonState(List.of(after),List.of())),"administrator_room_create");
|
||||
first.close();first.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);assertTrue(failures.isEmpty());assertEquals(1,rejected.size());
|
||||
var original=rejected.getFirst();assertEquals("dungeon.room.created",original.type());assertEquals(12,((Number)((Map<?,?>)original.data().get("after_bounds")).get("min_x")).intValue());
|
||||
var replayed=new java.util.concurrent.CopyOnWriteArrayList<EventInput>();
|
||||
var second=new DungeonAuditEventBridge(event->{replayed.add(event);return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);},data,ignored->{throw new AssertionError("Replay must never capture a current administrator");},failures::add);
|
||||
second.replay();second.replay();second.close();second.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);assertTrue(failures.isEmpty());assertEquals(1,replayed.size());
|
||||
assertEquals(original,replayed.getFirst(),"Owner replay must retain the exact original ID/time/context and all typed nested properties, not reconstruct a current definition");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user