feat(dungeon): export original character boss audit events
Release / release (push) Failing after 16m7s
Release / release (push) Failing after 16m7s
This commit is contained in:
1 parent
de5391e8e4
commit
8b14eddf43
18 files changed
+1129
-17
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.CharacterBossEventBridge characterBossEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> characterBossDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.DungeonAuditEventBridge dungeonOwnerEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> dungeonOwnerDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.CampEventBridge campAuditEvents;
|
||||
@@ -931,6 +933,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
sender -> dungeonActorCapture.actor(sender, null), failure -> getLogger().log(Level.WARNING, "Dungeon owner event intent failed", failure));
|
||||
admin.attachDungeonEvents(dungeonOwnerEvents);
|
||||
dungeonCommands.attachOwnerEvents(dungeonOwnerEvents);
|
||||
var characterBossActorCapture = dungeonOwnerEvents;
|
||||
characterBossEvents = new games.dmg.fantasystick.events.CharacterBossEventBridge(eventLog::emit, getDataFolder().toPath(),
|
||||
characterBossActorCapture::actor, failure -> getLogger().log(Level.WARNING, "Character boss event intent failed", failure));
|
||||
dungeonCommands.attachCharacterBossEvents(characterBossEvents);
|
||||
dungeonController.attachCharacterBossEvents(characterBossEvents);
|
||||
dungeonController.attachOwnerEvents(dungeonOwnerEvents);
|
||||
adventureQuestController.attachDungeonEvents(dungeonOwnerEvents, dungeons::find);
|
||||
var observedDungeonDirectory = dungeons;
|
||||
@@ -1148,6 +1155,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 replayCharacterBossEvents = characterBossEvents;
|
||||
characterBossDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous character boss event drain failed", failure);
|
||||
if (replayCharacterBossEvents != null) replayCharacterBossEvents.replay();
|
||||
});
|
||||
var replayDungeonOwnerEvents = dungeonOwnerEvents;
|
||||
dungeonOwnerDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous dungeon owner event drain failed", failure);
|
||||
@@ -1283,6 +1295,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
|
||||
var closingKnockbackEvents = stickKnockbackEvents; stickKnockbackEvents = null;
|
||||
var closingHardMobXpEvents = hardMobXpAuditEvents; hardMobXpAuditEvents = null;
|
||||
var closingCharacterBossEvents = characterBossEvents; characterBossEvents = null;
|
||||
var closingDungeonOwnerEvents = dungeonOwnerEvents; dungeonOwnerEvents = null;
|
||||
var closingCampEvents = campAuditEvents; campAuditEvents = null;
|
||||
var closingChampionEvents = championAuditEvents; championAuditEvents = null;
|
||||
@@ -1666,6 +1679,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingHardMobXpEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingHardMobXpEvents.drained());
|
||||
});
|
||||
if (closingCharacterBossEvents != null) cleanup("character boss event intent drain", () -> {
|
||||
var dungeonDrained = closingDungeons == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingDungeons.drained();
|
||||
var charactersDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
characterBossDrain = java.util.concurrent.CompletableFuture.allOf(dungeonDrained.toCompletableFuture(), charactersDrained.toCompletableFuture())
|
||||
.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character boss authority drain failed", failure);
|
||||
closingCharacterBossEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingCharacterBossEvents.drained());
|
||||
});
|
||||
if (closingDungeonOwnerEvents != null) cleanup("dungeon owner event intent drain", () -> {
|
||||
var dungeonDrained = closingDungeons == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingDungeons.drained();
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package games.dmg.fantasystick.dungeon;
|
||||
|
||||
import games.dmg.fantasystick.events.CharacterBossEventBridge;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Read-only, native-thread observations. Never owns, removes or restores a potion layer. */
|
||||
final class CharacterBossEffectAudit {
|
||||
private record Key(UUID target, PotionEffectType type) {}
|
||||
private record Application(UUID id, UUID shell, CharacterBossEventBridge.Facts facts,
|
||||
PotionEffect signature) {}
|
||||
record NativeCall(CharacterBossEventBridge.Facts facts, LivingEntity target,
|
||||
PotionEffect requested, PotionEffect prior, boolean returned,
|
||||
List<EntityPotionEffectEvent> nativeEvents) {
|
||||
NativeCall { nativeEvents = List.copyOf(nativeEvents); }
|
||||
}
|
||||
private final Map<Key, Application> owned = new HashMap<>();
|
||||
private final CharacterBossEventBridge events;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
|
||||
CharacterBossEffectAudit(CharacterBossEventBridge events, Consumer<Runnable> nextTick) {
|
||||
this.events = events;
|
||||
this.nextTick = nextTick;
|
||||
}
|
||||
|
||||
static PotionEffect layer(LivingEntity target, PotionEffectType type) {
|
||||
return target.getActivePotionEffects().stream().filter(effect -> effect.getType().equals(type)).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
// A passive has no cast event. Require the exact requested infinite native layer after confirmed spawn.
|
||||
void passive(CharacterBossEventBridge.Facts shell, LivingEntity target, String id,
|
||||
PotionEffect requested, Map<String,Object> location) {
|
||||
if (requested.equals(layer(target, requested.getType()))) {
|
||||
var facts = events.transition(shell, Map.of("passive_id", id,
|
||||
"source_location", location, "target_location", location,
|
||||
"target_kind", "native_character_shell"));
|
||||
apply(facts, target, requested, "confirmed_equipped_passive");
|
||||
}
|
||||
}
|
||||
|
||||
String resolve(List<NativeCall> calls) {
|
||||
if (calls.isEmpty()) return null;
|
||||
boolean accepted = false, cancelled = false, unchanged = false;
|
||||
for (var call : calls) {
|
||||
// Ambiguous reentrant observations cannot stand in for the original single native call.
|
||||
if (call.nativeEvents().size() != 1) continue;
|
||||
var event = call.nativeEvents().getFirst();
|
||||
if (event.isCancelled()) { cancelled = true; continue; }
|
||||
var current = layer(call.target(), call.requested().getType());
|
||||
if (call.requested().equals(call.prior()) && call.requested().equals(current)) {
|
||||
unchanged = true;
|
||||
continue;
|
||||
}
|
||||
// Source may be null in pinned Bukkit. Exact per-call target/request plus final decision,
|
||||
// actual returned acceptance and a changed native layer establish this observation.
|
||||
if (call.returned() && call.requested().equals(event.getNewEffect())
|
||||
&& call.requested().equals(current)) {
|
||||
apply(call.facts(), call.target(), call.requested(), "native_call_changed_layer_confirmed");
|
||||
accepted = true;
|
||||
}
|
||||
}
|
||||
return accepted ? "accepted" : cancelled ? "cancelled" : unchanged ? "no_op" : "unconfirmed";
|
||||
}
|
||||
|
||||
private void apply(CharacterBossEventBridge.Facts facts, LivingEntity target,
|
||||
PotionEffect signature, String status) {
|
||||
var key = new Key(target.getUniqueId(), signature.getType());
|
||||
var previous = owned.get(key);
|
||||
if (previous != null) end(key, previous, nativeEnd(previous, null, target),
|
||||
"native_layer_replaced", "confirmed_replacement");
|
||||
var id = UUID.randomUUID();
|
||||
var data = new LinkedHashMap<String,Object>();
|
||||
data.put("application_id", id.toString());
|
||||
data.put("effect_type", signature.getType().getKey().toString());
|
||||
data.put("amplifier", signature.getAmplifier());
|
||||
data.put("duration_ticks", signature.getDuration());
|
||||
data.put("particles", signature.hasParticles());
|
||||
data.put("target_entity_id", target.getUniqueId().toString());
|
||||
data.put("application_status", status);
|
||||
data.put("causation", "original_character_boss_native_effect");
|
||||
var original = events.transition(facts, data);
|
||||
owned.put(key, new Application(id, UUID.fromString(facts.data().get("native_entity_id").toString()),
|
||||
original, signature));
|
||||
events.effectApplied(original, id, Map.of("outcome", "accepted"));
|
||||
}
|
||||
|
||||
// Removal listeners run before the server mutates its layer. Confirm after dispatch AND mutation.
|
||||
void removal(EntityPotionEffectEvent event) {
|
||||
if (event.getAction() != EntityPotionEffectEvent.Action.REMOVED
|
||||
&& event.getAction() != EntityPotionEffectEvent.Action.CHANGED) return;
|
||||
if (!(event.getEntity() instanceof LivingEntity target)) return;
|
||||
var old = event.getOldEffect();
|
||||
if (old == null) return;
|
||||
var key = new Key(target.getUniqueId(), old.getType());
|
||||
var application = owned.get(key);
|
||||
if (application == null || !sameOwnedLayer(application.signature(), old)) return;
|
||||
var cause = event.getCause().name().toLowerCase(java.util.Locale.ROOT);
|
||||
var endFacts = nativeEnd(application, event.getSource(), target);
|
||||
nextTick.accept(() -> {
|
||||
try {
|
||||
if (!event.isCancelled() && owned.get(key) == application
|
||||
&& (layer(target, old.getType()) == null
|
||||
|| !sameOwnedLayer(application.signature(), layer(target, old.getType())))) {
|
||||
end(key, application, endFacts, "native_" + cause,
|
||||
"native_layer_no_longer_matches");
|
||||
}
|
||||
} catch (RuntimeException unavailable) { /* Audit must not affect native expiration. */ }
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean sameOwnedLayer(PotionEffect original, PotionEffect observed) {
|
||||
return original.getType().equals(observed.getType()) && original.getAmplifier() == observed.getAmplifier()
|
||||
&& original.hasParticles() == observed.hasParticles() && original.isAmbient() == observed.isAmbient()
|
||||
&& (original.getDuration() == PotionEffect.INFINITE_DURATION
|
||||
? observed.getDuration() == PotionEffect.INFINITE_DURATION
|
||||
: observed.getDuration() <= original.getDuration());
|
||||
}
|
||||
|
||||
void shellEnded(Entity shell, String reason) {
|
||||
var id = shell.getUniqueId();
|
||||
for (var entry : new ArrayList<>(owned.entrySet())) {
|
||||
var application = entry.getValue();
|
||||
if (application.shell().equals(id) && entry.getKey().target().equals(id))
|
||||
end(entry.getKey(), application, nativeEnd(application, shell, shell),
|
||||
reason, "native_shell_lifetime_ended");
|
||||
}
|
||||
}
|
||||
|
||||
void shellEndUnconfirmed(Entity shell, String reason, String status) {
|
||||
var id = shell.getUniqueId();
|
||||
for (var entry : new ArrayList<>(owned.entrySet())) {
|
||||
var key = entry.getKey(); var application = entry.getValue();
|
||||
if (!application.shell().equals(id) || !key.target().equals(id)) continue;
|
||||
try {
|
||||
var original = nativeEnd(application, shell, shell);
|
||||
events.effectEndPending(original, application.id(), Map.of("phase", "native_effect_end_confirmation_pending",
|
||||
"application_id", application.id().toString(), "outcome", "unconfirmed",
|
||||
"end_status", status, "reason", reason, "causation", "original_native_shell_remove_returned"));
|
||||
// One native-thread confirmation, not a worker/replay retry or gameplay cleanup.
|
||||
nextTick.accept(() -> {
|
||||
try { if (!shell.isValid() && owned.get(key) == application)
|
||||
end(key, application, original, "native_invalid_confirmed_after_original_remove",
|
||||
"native_shell_lifetime_ended_after_original_unconfirmed_remove");
|
||||
} catch (RuntimeException unavailable) { /* Keep immutable pending application evidence, never invent an end. */ }
|
||||
});
|
||||
} catch (RuntimeException unavailable) { /* Each original application remains independent. */ }
|
||||
}
|
||||
}
|
||||
|
||||
private CharacterBossEventBridge.Facts nativeEnd(Application application, Entity source, Entity target) {
|
||||
var original = application.facts();
|
||||
var data = new LinkedHashMap<String,Object>();
|
||||
if (original.data().containsKey("source_location"))
|
||||
data.put("application_source_location", original.data().get("source_location"));
|
||||
if (original.data().containsKey("target_location"))
|
||||
data.put("application_target_location", original.data().get("target_location"));
|
||||
data.put("application_observed_at", original.request().observedAt().toString());
|
||||
data.put("source_location", nativeEndLocation(source));
|
||||
data.put("target_location", nativeEndLocation(target));
|
||||
if (source != null) data.put("end_event_source_entity_id", source.getUniqueId().toString());
|
||||
data.put("end_capture_quality", "original_native_transition_before_confirmation");
|
||||
return events.transition(original, data);
|
||||
}
|
||||
|
||||
private static Map<String,Object> nativeEndLocation(Entity entity) {
|
||||
try {
|
||||
var at = entity == null ? null : entity.getLocation();
|
||||
if (at == null || at.getWorld() == null)
|
||||
return Map.of("location_status", "unavailable_at_original_native_end");
|
||||
var data = new LinkedHashMap<String,Object>();
|
||||
data.put("world_id", at.getWorld().getUID().toString());
|
||||
data.put("world_name", java.util.Objects.toString(at.getWorld().getName(), "unavailable"));
|
||||
data.put("x", at.getX()); data.put("y", at.getY()); data.put("z", at.getZ());
|
||||
data.put("yaw", (double)at.getYaw()); data.put("pitch", (double)at.getPitch());
|
||||
data.put("captured_at", java.time.Instant.now().toString());
|
||||
data.put("quality", "native_original");
|
||||
return Map.copyOf(data);
|
||||
} catch (RuntimeException unavailable) {
|
||||
return Map.of("location_status", "unavailable_at_original_native_end");
|
||||
}
|
||||
}
|
||||
|
||||
private void end(Key key, Application application, CharacterBossEventBridge.Facts facts,
|
||||
String reason, String status) {
|
||||
if (!owned.remove(key, application)) return;
|
||||
events.effectEnded(facts, application.id(), Map.of("application_id", application.id().toString(),
|
||||
"reason", reason, "cleanup_status", status, "outcome", "ended",
|
||||
"causation", "original_character_boss_effect_application"));
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,12 @@ 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 games.dmg.fantasystick.events.DungeonAuditEventBridge ownerEvents;
|
||||
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();private final AdventureAdminTeleport teleports;private games.dmg.fantasystick.events.DungeonEventBridge auditEvents;private games.dmg.fantasystick.events.DungeonAuditEventBridge ownerEvents;private games.dmg.fantasystick.events.CharacterBossEventBridge characterBossEvents;
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick){this(plugin,directory,controller,adventures,nextTick,id->CompletableFuture.completedFuture(Optional.empty()),id->false,id->false);}
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick,Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters,Predicate<UUID> activeCharacter,Predicate<UUID> partyCharacter){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.adventures=Objects.requireNonNull(adventures);this.nextTick=Objects.requireNonNull(nextTick);this.characters=Objects.requireNonNull(characters);this.activeCharacter=Objects.requireNonNull(activeCharacter);this.partyCharacter=Objects.requireNonNull(partyCharacter);locations=new games.dmg.fantasystick.adventure.AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
|
||||
public void attachEvents(games.dmg.fantasystick.events.DungeonEventBridge events){this.auditEvents=Objects.requireNonNull(events);}
|
||||
public void attachOwnerEvents(games.dmg.fantasystick.events.DungeonAuditEventBridge events){this.ownerEvents=Objects.requireNonNull(events);}
|
||||
public void attachCharacterBossEvents(games.dmg.fantasystick.events.CharacterBossEventBridge events){this.characterBossEvents=Objects.requireNonNull(events);}
|
||||
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"mode"->mode(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"reward"->reward(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
|
||||
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}int nameStart=2;var mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args[2].equals("--mode")){if(args.length<5){usage(sender);return;}try{mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}nameStart=4;}else if(args[2].startsWith("--")){usage(sender);return;}String name=join(args,nameStart);if(name.isBlank()){usage(sender);return;}var location=player.getLocation();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")+"."));}
|
||||
@@ -37,9 +38,9 @@ public final class DungeonCommands {
|
||||
private void effect(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=7&&args[3].equals("add")){DungeonState.EffectTarget target;try{target=DungeonState.EffectTarget.valueOf(args[4].toUpperCase(Locale.ROOT));}catch(IllegalArgumentException invalid){usage(sender);return;}String key=args[5].contains(":")?args[5]:"minecraft:"+args[5];var namespaced=NamespacedKey.fromString(key);Integer level=integer(args[6]);boolean particles=args.length<8||Boolean.parseBoolean(args[7]);if(namespaced==null||PotionEffectType.getByKey(namespaced)==null||level==null||args.length>8||args.length==8&&!Set.of("true","false").contains(args[7])){usage(sender);return;}observedSave(sender,"administrator_effect_add",()->directory.addEffect(dungeon,room.id(),target,key,level,particles)).whenComplete((value,failure)->saved(sender,failure,"Added room effect "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}observedSave(sender,"administrator_effect_remove",()->directory.removeEffect(dungeon,room.id(),id)).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed room effect "+id+".":"Room effect not found."));return;}usage(sender);}
|
||||
private void reward(CommandSender sender,String[] args){if(args.length<3||args.length>5){usage(sender);return;}UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage(current.rewardConfigured()?"Dungeon "+dungeon+" completion reward is "+current.completionReward().xp()+" XP and "+current.completionReward().coins()+" coins.":"Dungeon "+dungeon+" completion reward is not configured.");return;}if(current.enabled()||adventures.offerOpen(dungeon)||adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon))){sender.sendMessage("Dungeon reward update requires disabled, closed dungeon state without accepted quests.");return;}if(controller.beingEdited(dungeon)&&(!(sender instanceof Player player)||!controller.editing(player.getUniqueId()).filter(dungeon::equals).isPresent())){sender.sendMessage("Dungeon reward update refused: another administrator owns the editing lease.");return;}BigInteger xp=args.length==5?whole(args[3]):null,coins=args.length==5?whole(args[4]):null;if(xp==null||coins==null){usage(sender);return;}var audit=auditEvents;var actor=audit==null?null:audit.actor(sender);var operation=UUID.randomUUID();directory.setCompletionRewardWithEvidence(dungeon,xp,coins).whenComplete((change,failure)->{if(failure==null&&audit!=null)try{audit.rewardChanged(operation,actor,change);}catch(RuntimeException ignored){}saved(sender,failure,"Set dungeon completion reward to "+xp+" XP and "+coins+" coins.");});}
|
||||
private void boss(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length==5&&args[3].equals("assign-sheet")){assignCharacterBoss(sender,player,dungeon,room,args[4]);return;}if(args.length>=5&&args[3].equals("mob")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living boss type.");return;}var location=player.getLocation();observedSave(sender,"administrator_native_boss",()->directory.setBoss(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch())).whenComplete((value,failure)->saved(sender,failure,"Configured native boss "+type.name().toLowerCase(Locale.ROOT)+"."));return;}if(args.length==4&&args[3].equals("clear")){observedSave(sender,"administrator_native_boss_clear",()->directory.clearBoss(dungeon,room.id())).whenComplete((value,failure)->saved(sender,failure,"Cleared the room boss."));return;}usage(sender);}
|
||||
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var sheetIdentity=sheets.identity(held).orElse(null);var character=sheetIdentity==null?null:sheetIdentity.character();if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}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 assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var sheetIdentity=sheets.identity(held).orElse(null);var character=sheetIdentity==null?null:sheetIdentity.character();if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}var audit=characterBossEvents;var request=audit==null?null:audit.request(sender);characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();if(!sheetIdentity.generation().equals(expected.sheetGeneration())||(sheetIdentity.legacy()&&expected.sheetGeneration().signum()!=0)){respond(sender,()->sender.sendMessage("Character boss assignment refused: that sheet generation is no longer authorized."));return;}var assignment=directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier);assignment.whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.custodySaved(audit.assignment(request,publication,dungeon,room.id(),custody)));}catch(RuntimeException ignored){/* Saved custody observation never gates native revalidation or consumption. */}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){var rollback=directory.cancelAssignment(dungeon,custody.operation());rollback.whenComplete((applied,rollbackFailure)->{if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.rolledBack(audit.assignment(request,publication,dungeon,room.id(),custody),rollbackFailure==null&&Boolean.TRUE.equals(applied),DungeonDirectory.publication(rollback).orElse(null),"saved_character_changed"));}catch(RuntimeException ignored){/* Original rollback observation cannot change character/custody authority. */}respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing."));});return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||!sheets.authorizes(current,expected)){var rollback=directory.cancelAssignment(dungeon,custody.operation());rollback.whenComplete((applied,rollbackFailure)->{if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.rolledBack(audit.assignment(request,publication,dungeon,room.id(),custody),rollbackFailure==null&&Boolean.TRUE.equals(applied),DungeonDirectory.publication(rollback).orElse(null),"held_sheet_changed"));}catch(RuntimeException ignored){/* Original rollback observation cannot change inventory/custody authority. */}respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing."));});return;}player.getInventory().setItemInMainHand(null);if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.assigned(audit.assignment(request,publication,dungeon,room.id(),custody)));}catch(RuntimeException ignored){/* Optional observation cannot interrupt physical consumption. */}sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
|
||||
private void bossCommand(CommandSender sender,String[] args){if(args.length!=4||!args[2].equals("recover-sheet")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=id(args[3]);if(dungeon==null){usage(sender);return;}recoverCharacterBoss(sender,player,dungeon);}
|
||||
private void recoverCharacterBoss(CommandSender sender,Player player,UUID dungeon){var definition=directory.find(dungeon).orElse(null);var custody=directory.custodyForDungeon(dungeon).orElse(null);if(definition==null||custody==null){sender.sendMessage("Dungeon character boss custody was not found.");return;}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeon);boolean accepted=adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon));if(definition.enabled()||offered||accepted||controller.beingEdited(dungeon)||controller.editing(player.getUniqueId()).isPresent()||controller.occupied(dungeon)){sender.sendMessage("Recovery requires a disabled, closed, unedited and unoccupied dungeon with no accepted quests.");return;}var physical=plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents()).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(custody.character().id()::equals).isPresent()).map(item->Map.entry(holder,item))).toList();if(physical.size()>1||physical.size()==1&&physical.getFirst().getKey()!=player){sender.sendMessage("Recovery refused because existing physical-sheet custody is ambiguous.");return;}boolean delivered=!physical.isEmpty()||custody.recovery()!=null&&Arrays.stream(player.getInventory().getContents()).anyMatch(item->sheets.hasRecovery(item,custody.recovery().operation()));if(!delivered&&player.getInventory().firstEmpty()<0){sender.sendMessage("One empty inventory slot is required before recovery.");return;}directory.prepareRecovery(dungeon,player.getUniqueId()).whenComplete((prepared,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Character boss recovery could not be prepared; custody is retained.");return;}var operation=prepared.recovery().operation();org.bukkit.inventory.ItemStack sheet=null;for(var item:player.getInventory().getContents())if(sheets.hasRecovery(item,operation)||sheets.identify(item).filter(prepared.character().id()::equals).isPresent()){sheet=item;break;}if(sheet==null){int slot=player.getInventory().firstEmpty();if(slot<0){sender.sendMessage("Character boss recovery is prepared, but one empty inventory slot is required.");return;}sheet=sheets.create(prepared.character());sheets.markRecovery(sheet,operation);player.getInventory().setItem(slot,sheet);}var item=sheet;directory.completeRecovery(dungeon,operation).whenComplete((completed,completeFailure)->respond(sender,()->{if(completeFailure!=null||!completed){sender.sendMessage("Recovery sheet delivery is retained, but durable custody completion must be retried.");return;}sheets.clearRecovery(item);sender.sendMessage("Recovered character sheet for "+prepared.character().name()+" from dungeon "+dungeon+"; configure another boss before enabling it.");}));}));}
|
||||
private void recoverCharacterBoss(CommandSender sender,Player player,UUID dungeon){var definition=directory.find(dungeon).orElse(null);var custody=directory.custodyForDungeon(dungeon).orElse(null);if(definition==null||custody==null){sender.sendMessage("Dungeon character boss custody was not found.");return;}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeon);boolean accepted=adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon));if(definition.enabled()||offered||accepted||controller.beingEdited(dungeon)||controller.editing(player.getUniqueId()).isPresent()||controller.occupied(dungeon)){sender.sendMessage("Recovery requires a disabled, closed, unedited and unoccupied dungeon with no accepted quests.");return;}var physical=plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents()).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(custody.character().id()::equals).isPresent()).map(item->Map.entry(holder,item))).toList();if(physical.size()>1||physical.size()==1&&physical.getFirst().getKey()!=player){sender.sendMessage("Recovery refused because existing physical-sheet custody is ambiguous.");return;}boolean delivered=!physical.isEmpty()||custody.recovery()!=null&&Arrays.stream(player.getInventory().getContents()).anyMatch(item->sheets.hasRecovery(item,custody.recovery().operation()));if(!delivered&&player.getInventory().firstEmpty()<0){sender.sendMessage("One empty inventory slot is required before recovery.");return;}var audit=characterBossEvents;var request=audit==null?null:audit.request(sender);var preparation=directory.prepareRecovery(dungeon,player.getUniqueId());preparation.whenComplete((prepared,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Character boss recovery could not be prepared; custody is retained.");return;}var operation=prepared.recovery().operation();org.bukkit.inventory.ItemStack sheet=null;for(var item:player.getInventory().getContents())if(sheets.hasRecovery(item,operation)||sheets.identify(item).filter(prepared.character().id()::equals).isPresent()){sheet=item;break;}if(sheet==null){int slot=player.getInventory().firstEmpty();if(slot<0){sender.sendMessage("Character boss recovery is prepared, but one empty inventory slot is required.");return;}sheet=sheets.create(prepared.character());sheets.markRecovery(sheet,operation);player.getInventory().setItem(slot,sheet);}var item=sheet;games.dmg.fantasystick.events.CharacterBossEventBridge.Facts deliveryFacts=null;if(audit!=null)try{var view=DungeonDirectory.publication(preparation).flatMap(publication->publication.after().dungeons().stream().filter(value->value.id().equals(dungeon)).findFirst()).orElse(definition);var originalRoom=view.rooms().stream().filter(value->value.boss()!=null&&value.boss().character()!=null&&value.boss().character().operation().equals(prepared.operation())).findFirst().orElseThrow();deliveryFacts=audit.custody(request,view,originalRoom.id(),prepared);}catch(RuntimeException ignored){/* Delivery does not depend on optional metadata. */}var facts=deliveryFacts;var completion=directory.completeRecovery(dungeon,operation);completion.whenComplete((completed,completeFailure)->{if(completeFailure==null&&Boolean.TRUE.equals(completed)&&facts!=null)try{DungeonDirectory.publication(completion).ifPresent(publication->audit.recovered(facts,operation,publication,"exact_authorized_sheet_delivered"));}catch(RuntimeException ignored){/* Durable completion stays authoritative independently of feedback. */}if(facts!=null&&(completeFailure!=null||!Boolean.TRUE.equals(completed)))try{audit.recoveryPending(facts,operation,completeFailure!=null?"failed_final_source_save":"completion_not_acknowledged");}catch(RuntimeException ignored){/* Pending recovery observation cannot change custody or native delivery. */}respond(sender,()->{if(completeFailure!=null||!completed){sender.sendMessage("Recovery sheet delivery is retained, but durable custody completion must be retried.");return;}sheets.clearRecovery(item);sender.sendMessage("Recovered character sheet for "+prepared.character().name()+" from dungeon "+dungeon+"; configure another boss before enabling it.");});});}));}
|
||||
private void teleport(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Dungeon teleport requires a player.");return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var dungeon=directory.find(id).orElse(null);if(dungeon==null){sender.sendMessage("Dungeon not found.");return;}teleports.teleport(sender,"Dungeon",target(dungeon),()->directory.find(id).map(DungeonCommands::target));}
|
||||
private static AdventureAdminTeleport.Target target(DungeonState.Dungeon dungeon){var entrance=dungeon.entrance();return new AdventureAdminTeleport.Target(dungeon.id(),dungeon.name(),"dungeon entrance",dungeon.world(),entrance.x(),entrance.y(),entrance.z(),entrance.yaw(),entrance.pitch(),false);}
|
||||
private void enable(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}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.")));}
|
||||
|
||||
@@ -33,9 +33,26 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
private static final String ENEMY="dungeon_enemy",PROJECTILE="dungeon_projectile";
|
||||
private record AttemptKey(UUID dungeon,UUID room){}
|
||||
private games.dmg.fantasystick.events.DungeonAuditEventBridge ownerEvents;
|
||||
private games.dmg.fantasystick.events.CharacterBossEventBridge characterBossEvents;
|
||||
private final Map<UUID,games.dmg.fantasystick.events.CharacterBossEventBridge.Facts> characterBossShellFacts=new HashMap<>();
|
||||
private CharacterBossEffectAudit characterBossEffects;
|
||||
private record ObservedCharacterDamage(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,EntityDamageByEntityEvent event){}
|
||||
private static final class CharacterCast {
|
||||
final games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts;final UUID shell;final List<ObservedCharacterDamage> damage=new ArrayList<>();final List<CharacterBossEffectAudit.NativeCall> effects=new ArrayList<>();String nativeOutcome;
|
||||
CharacterCast(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,UUID shell){this.facts=facts;this.shell=shell;}
|
||||
}
|
||||
private final ThreadLocal<Deque<CharacterCast>> characterCasts=ThreadLocal.withInitial(ArrayDeque::new);
|
||||
private record CharacterPotion(CharacterCast cast,games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,LivingEntity target,PotionEffect requested,PotionEffect prior,List<EntityPotionEffectEvent> nativeEvents){}
|
||||
private final ThreadLocal<Deque<CharacterPotion>> characterPotions=ThreadLocal.withInitial(ArrayDeque::new);
|
||||
private record NativeKnockback(org.bukkit.event.entity.EntityKnockbackEvent event) {}
|
||||
private final ThreadLocal<List<NativeKnockback>> characterMeleeKnockback=new ThreadLocal<>();
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void characterNativeKnockback(org.bukkit.event.entity.EntityKnockbackEvent event){
|
||||
try{var calls=characterMeleeKnockback.get();if(calls!=null)calls.add(new NativeKnockback(event));}catch(RuntimeException unavailable){/* Native knockback remains authoritative. */}
|
||||
}
|
||||
public void attachCharacterBossEvents(games.dmg.fantasystick.events.CharacterBossEventBridge events){characterBossEvents=Objects.requireNonNull(events);characterBossEffects=new CharacterBossEffectAudit(events,nextTick);}
|
||||
public void attachOwnerEvents(games.dmg.fantasystick.events.DungeonAuditEventBridge events){ownerEvents=Objects.requireNonNull(events);}
|
||||
private record PendingKill(UUID entity,UUID dungeon,UUID room,UUID generation,UUID slot,games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts observation){}
|
||||
private static final class Attempt {final UUID dungeon,room,generation;final Map<UUID,UUID> entities=new HashMap<>();final Set<UUID> represented=new HashSet<>();final Map<UUID,Double> threat=new HashMap<>();final Map<String,Long> cooldowns=new HashMap<>();final Map<String,Long> effects=new HashMap<>();UUID boss,target,defeatedBoss;games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts observation,deathObservation;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 static final class Attempt {final UUID dungeon,room,generation;final Map<UUID,UUID> entities=new HashMap<>();final Set<UUID> represented=new HashSet<>();final Map<UUID,Double> threat=new HashMap<>();final Map<String,Long> cooldowns=new HashMap<>();final Map<String,Long> effects=new HashMap<>();UUID boss,target,defeatedBoss;games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts observation,deathObservation;games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterDeathObservation;Map<String,String> defeatAudit=Map.of();long lastCombat=System.nanoTime();Attempt(UUID dungeon,UUID room,UUID generation){this.dungeon=dungeon;this.room=room;this.generation=generation;}}
|
||||
private record Context(UUID character,UUID party,Set<UUID> members){}
|
||||
private record OwnedEffect(UUID dungeon,UUID player,PotionEffectType type,int amplifier,PotionEffect prior,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;
|
||||
@@ -97,7 +114,7 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
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())){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);}}
|
||||
@SuppressWarnings({"unchecked","rawtypes"}) private void spawnRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,DungeonState.Run run,Attempt attempt){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;for(var slot:room.mobs())if(!run.defeatedSlots().contains(slot.id())&&loaded(world,slot.position())){var spawned=world.spawn(location(world,slot.position()),(Class)slot.type().getEntityClass(),entity->configure((Mob)entity,dungeon,room,attempt,slot.id(),false,slot.name(),room.effects()));observeSpawn(dungeon,room,attempt,spawned,slot.id(),false);}if(room.boss()!=null&&loaded(world,room.boss().position())){var spawned=world.spawn(location(world,room.boss().position()),(Class)room.boss().type().getEntityClass(),entity->configure((Mob)entity,dungeon,room,attempt,null,true,room.boss().name(),room.effects()));if(room.boss().character()==null)observeSpawn(dungeon,room,attempt,spawned,null,true);else observeCharacterBossSpawn(dungeon,room,attempt,spawned);}}
|
||||
private void configure(Mob mob,DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,UUID slot,boolean boss,String name,List<DungeonState.Effect> effects){var data=mob.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,ENEMY);data.set(dungeonKey,PersistentDataType.STRING,dungeon.id().toString());data.set(roomKey,PersistentDataType.STRING,room.id().toString());data.set(generationKey,PersistentDataType.STRING,attempt.generation.toString());data.set(bossKey,PersistentDataType.BOOLEAN,boss);if(slot!=null)data.set(slotKey,PersistentDataType.STRING,slot.toString());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}if(mob instanceof Zombie zombie)zombie.setShouldBurnInDay(false);if(!name.isBlank()){mob.customName(net.kyori.adventure.text.Component.text(name));mob.setCustomNameVisible(true);}if(boss){attempt.boss=mob.getUniqueId();if(room.boss()!=null&&room.boss().character()!=null)configureCharacterBoss(mob,room.boss().character());}else attempt.entities.put(mob.getUniqueId(),slot);applyMobEffects(mob,effects);}
|
||||
private void configureCharacterBoss(Mob mob,DungeonState.CharacterBoss custody){var stats=custody.effectiveStats();var maximum=new java.math.BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN)).min(games.dmg.fantasystick.session.NativeHealthLimit.read()).max(java.math.BigDecimal.ONE).doubleValue();var health=mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);if(health!=null){health.setBaseValue(maximum);mob.setHealth(maximum);}var damage=mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE);if(damage!=null)damage.setBaseValue(Math.min(characterDamage(stats.get(Stat.STR)),nativeAttributeMaximum(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)));var equipment=mob.getEquipment();if(equipment!=null){var gear=custody.character().gear();equipment.setChestplate(armor(org.bukkit.inventory.EquipmentSlot.CHEST,gear.chestplate()));equipment.setLeggings(armor(org.bukkit.inventory.EquipmentSlot.LEGS,gear.leggings()));equipment.setBoots(armor(org.bukkit.inventory.EquipmentSlot.FEET,gear.boots()));equipment.setChestplateDropChance(0);equipment.setLeggingsDropChance(0);equipment.setBootsDropChance(0);}for(var id:custody.character().passives())if(passives.find(id).isPresent()){if(id.equals("speed_i"))mob.addPotionEffect(new PotionEffect(PotionEffectType.SPEED,PotionEffect.INFINITE_DURATION,0,false,false));else if(id.equals("low_light_vision"))mob.addPotionEffect(new PotionEffect(PotionEffectType.NIGHT_VISION,PotionEffect.INFINITE_DURATION,0,false,false));}}
|
||||
private static double nativeAttributeMaximum(org.bukkit.attribute.Attribute attribute){try{var handle=attribute.getClass().getMethod("getHandle").invoke(attribute);double maximum=((Number)handle.getClass().getMethod("getMaxValue").invoke(handle)).doubleValue();if(!Double.isFinite(maximum)||maximum<=0)throw new IllegalStateException("Invalid native attribute range");return maximum;}catch(ReflectiveOperationException|ClassCastException failure){throw new IllegalStateException("Cannot read native attribute range",failure);}}
|
||||
@@ -106,8 +123,8 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
private static double abilityAmount(AbilityDefinition definition,DungeonState.CharacterBoss custody,boolean healing){var stats=custody.effectiveStats();Stat stat=healing?Stat.WIS:switch(definition.id()){case"great_sword","shield_bash"->Stat.STR;case"longbow"->Stat.DEX;case"hex"->Stat.CHR;default->Stat.INT;};var base=definition.parameters().get(healing?"base-healing":"base-damage");var exact=(base==null?BigInteger.ZERO:base.toBigInteger()).add(modifier(stats.get(stat))).max(BigInteger.ZERO);return exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();}
|
||||
private static double range(AbilityDefinition definition){for(var key:List.of("range","initial-range","target-range","placement-range")){var value=definition.parameters().get(key);if(value!=null)return value.doubleValue();}return definition.id().equals("great_sword")?4d:16d;}
|
||||
private static org.bukkit.inventory.ItemStack armor(org.bukkit.inventory.EquipmentSlot slot,games.dmg.fantasystick.persistence.GearTier tier){if(tier==games.dmg.fantasystick.persistence.GearTier.NONE)return null;String material=(tier==games.dmg.fantasystick.persistence.GearTier.GOLD?"GOLDEN":tier.name())+"_"+switch(slot){case CHEST->"CHESTPLATE";case LEGS->"LEGGINGS";case FEET->"BOOTS";default->throw new IllegalArgumentException("Not armor");};return new org.bukkit.inventory.ItemStack(Material.valueOf(material));}
|
||||
private void driveCharacterBoss(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,List<Player> eligible){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;Mob boss=null;for(var entity:safe(world.getEntities()))if(entity instanceof Mob mob&&Objects.equals(attempt.boss,mob.getUniqueId())){boss=mob;break;}if(boss==null)return;var actor=boss;long now=System.nanoTime();if(attempt.effects.getOrDefault("absorption",Long.MAX_VALUE)<=now){boss.setAbsorptionAmount(0);attempt.effects.remove("absorption");}var eligibleIds=eligible.stream().map(Player::getUniqueId).collect(java.util.stream.Collectors.toSet());attempt.threat.keySet().removeIf(id->!eligibleIds.contains(id));Player target=eligible.stream().filter(player->player.getUniqueId().equals(attempt.target)&&!player.isDead()&&actor.hasLineOfSight(player)).findFirst().orElse(null);if(target==null&&!attempt.threat.isEmpty()){target=eligible.stream().filter(player->!player.isDead()&&actor.hasLineOfSight(player)&&attempt.threat.containsKey(player.getUniqueId())).max(Comparator.<Player>comparingDouble(player->attempt.threat.getOrDefault(player.getUniqueId(),0d)).thenComparing(player->player.getUniqueId().toString(),Comparator.reverseOrder())).orElse(null);attempt.target=target==null?null:target.getUniqueId();}if(target==null){regenerateCharacterBoss(boss,room.boss().character(),attempt);return;}if(boss.getTarget()!=target)boss.setTarget(target);castCharacterAbility(boss,target,eligible,room.boss().character(),attempt);}
|
||||
private void regenerateCharacterBoss(Mob boss,DungeonState.CharacterBoss custody,Attempt attempt){var settings=recoverySettings.get();long now=System.nanoTime();long ready=attempt.cooldowns.getOrDefault("recovery",attempt.lastCombat+settings.delay().toNanos());if(now<ready)return;var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);var amount=settings.amount().add(recoveryBonus.apply(custody.character()));if(maximum!=null&&amount.signum()>0&&boss.getHealth()<maximum.getValue())healBoss(boss,Math.min(Double.MAX_VALUE,amount.doubleValue()));attempt.cooldowns.put("recovery",now+settings.interval().toNanos());}
|
||||
private void driveCharacterBoss(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,List<Player> eligible){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;Mob boss=null;for(var entity:safe(world.getEntities()))if(entity instanceof Mob mob&&Objects.equals(attempt.boss,mob.getUniqueId())){boss=mob;break;}if(boss==null)return;var actor=boss;long now=System.nanoTime();if(attempt.effects.getOrDefault("absorption",Long.MAX_VALUE)<=now){boss.setAbsorptionAmount(0);attempt.effects.remove("absorption");}var eligibleIds=eligible.stream().map(Player::getUniqueId).collect(java.util.stream.Collectors.toSet());attempt.threat.keySet().removeIf(id->!eligibleIds.contains(id));Player target=eligible.stream().filter(player->player.getUniqueId().equals(attempt.target)&&!player.isDead()&&actor.hasLineOfSight(player)).findFirst().orElse(null);if(target==null&&!attempt.threat.isEmpty()){target=eligible.stream().filter(player->!player.isDead()&&actor.hasLineOfSight(player)&&attempt.threat.containsKey(player.getUniqueId())).max(Comparator.<Player>comparingDouble(player->attempt.threat.getOrDefault(player.getUniqueId(),0d)).thenComparing(player->player.getUniqueId().toString(),Comparator.reverseOrder())).orElse(null);attempt.target=target==null?null:target.getUniqueId();}if(target==null){regenerateCharacterBoss(boss,room.boss().character(),attempt);return;}var previous=boss.getTarget();if(previous!=target){var observation=characterTargetFacts(boss,target,previous,attempt);boss.setTarget(target);observeCharacterTarget(observation,boss,target);}castCharacterAbility(boss,target,eligible,room.boss().character(),attempt);}
|
||||
private void regenerateCharacterBoss(Mob boss,DungeonState.CharacterBoss custody,Attempt attempt){var settings=recoverySettings.get();long now=System.nanoTime();long ready=attempt.cooldowns.getOrDefault("recovery",attempt.lastCombat+settings.delay().toNanos());if(now<ready)return;var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);var amount=settings.amount().add(recoveryBonus.apply(custody.character()));if(maximum!=null&&amount.signum()>0&&boss.getHealth()<maximum.getValue())healBoss(boss,Math.min(Double.MAX_VALUE,amount.doubleValue()),characterPassiveHealingFacts(boss,custody,"out_of_combat_recovery",settings.amount(),amount.subtract(settings.amount())));attempt.cooldowns.put("recovery",now+settings.interval().toNanos());}
|
||||
private void castCharacterAbility(Mob boss,Player target,List<Player> eligible,DungeonState.CharacterBoss custody,Attempt attempt){
|
||||
long now=System.nanoTime();
|
||||
for(String id:custody.character().loadout()){
|
||||
@@ -120,23 +137,28 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
double range=range(definition);
|
||||
if(definition.category()==AbilityDefinition.Category.ATK&&(definition.targetPolicy()!=AbilityDefinition.TargetPolicy.AREA||id.equals("meteor"))
|
||||
&&(!boss.hasLineOfSight(target)||boss.getLocation().distanceSquared(target.getLocation())>range*range))continue;
|
||||
var observation=beginCharacterCast(boss,target,definition,custody.character().loadout().indexOf(id));
|
||||
try{
|
||||
boolean cast;
|
||||
if(id.equals("purify")){
|
||||
var harmful=boss.getActivePotionEffects().stream().filter(effect->effect.getType().getEffectCategory().name().equals("HARMFUL")).toList();
|
||||
for(var effect:harmful)boss.removePotionEffect(effect.getType());
|
||||
cast=!harmful.isEmpty();
|
||||
}else if(id.equals("hex")){
|
||||
target.addPotionEffect(new PotionEffect(PotionEffectType.WEAKNESS,Math.max(20,(int)Math.min(1200,definition.cooldown().toMillis()/50)),0,false,true),true);cast=true;
|
||||
characterPotion(target,new PotionEffect(PotionEffectType.WEAKNESS,Math.max(20,(int)Math.min(1200,definition.cooldown().toMillis()/50)),0,false,true),true);cast=true;
|
||||
}else cast=switch(definition.category()){
|
||||
case HEAL->{yield healBoss(boss,abilityAmount(definition,custody,true));}
|
||||
case BUF->{if(id.equals("quickstep")){var direction=target.getLocation().toVector().subtract(boss.getLocation().toVector());double distance=definition.parameters().getOrDefault("distance",java.math.BigDecimal.ONE).doubleValue();if(direction.lengthSquared()==0||!Double.isFinite(distance)||distance<=0)yield false;boss.setVelocity(direction.normalize().multiply(distance));yield true;}if(id.equals("inspire")){double amount=definition.parameters().getOrDefault("base-temporary-health",java.math.BigDecimal.ZERO).add(new java.math.BigDecimal(modifier(custody.effectiveStats().get(Stat.CHR)))).max(java.math.BigDecimal.ZERO).doubleValue();if(amount<=boss.getAbsorptionAmount())yield false;boss.setAbsorptionAmount(amount);attempt.effects.put("absorption",now+java.time.Duration.ofMillis(durationTicks(definition)*50L).toNanos());yield true;}var type=id.equals("stone_skin")?PotionEffectType.RESISTANCE:id.equals("rallying_cry")?PotionEffectType.SPEED:PotionEffectType.STRENGTH;boss.addPotionEffect(new PotionEffect(type,durationTicks(definition),0,false,false),true);yield true;}
|
||||
case ATK->{var victims=abilityVictims(definition,boss,target,eligible,range);double amount=abilityAmount(definition,custody,false);if(amount<=0)yield false;for(int index=0;index<victims.size();index++){var victim=victims.get(index);double strike=id.equals("chain_lightning")?java.math.BigDecimal.valueOf(amount).multiply(definition.parameters().getOrDefault("jump-multiplier",java.math.BigDecimal.ONE).pow(index)).doubleValue():amount;victim.damage(strike,boss);if(id.equals("frost_shard")||id.equals("frost_nova"))victim.addPotionEffect(new PotionEffect(PotionEffectType.SLOWNESS,40,0,false,true),true);}yield !victims.isEmpty();}
|
||||
case BUF->{if(id.equals("quickstep")){var direction=target.getLocation().toVector().subtract(boss.getLocation().toVector());double distance=definition.parameters().getOrDefault("distance",java.math.BigDecimal.ONE).doubleValue();if(direction.lengthSquared()==0||!Double.isFinite(distance)||distance<=0)yield false;boss.setVelocity(direction.normalize().multiply(distance));yield true;}if(id.equals("inspire")){double amount=definition.parameters().getOrDefault("base-temporary-health",java.math.BigDecimal.ZERO).add(new java.math.BigDecimal(modifier(custody.effectiveStats().get(Stat.CHR)))).max(java.math.BigDecimal.ZERO).doubleValue();if(amount<=boss.getAbsorptionAmount())yield false;boss.setAbsorptionAmount(amount);attempt.effects.put("absorption",now+java.time.Duration.ofMillis(durationTicks(definition)*50L).toNanos());yield true;}var type=id.equals("stone_skin")?PotionEffectType.RESISTANCE:id.equals("rallying_cry")?PotionEffectType.SPEED:PotionEffectType.STRENGTH;characterPotion(boss,new PotionEffect(type,durationTicks(definition),0,false,false),true);yield true;}
|
||||
case ATK->{var victims=abilityVictims(definition,boss,target,eligible,range);double amount=abilityAmount(definition,custody,false);if(amount<=0)yield false;for(int index=0;index<victims.size();index++){var victim=victims.get(index);double strike=id.equals("chain_lightning")?java.math.BigDecimal.valueOf(amount).multiply(definition.parameters().getOrDefault("jump-multiplier",java.math.BigDecimal.ONE).pow(index)).doubleValue():amount;victim.damage(strike,boss);if(id.equals("frost_shard")||id.equals("frost_nova"))characterPotion(victim,new PotionEffect(PotionEffectType.SLOWNESS,40,0,false,true),true);}yield !victims.isEmpty();}
|
||||
};
|
||||
if(cast)attempt.cooldowns.put(id,now+definition.cooldown().toNanos());
|
||||
completeCharacterCast(observation,cast,definition.category());
|
||||
}finally{endCharacterCast(observation);}
|
||||
}
|
||||
}
|
||||
private static int durationTicks(AbilityDefinition definition){var seconds=definition.parameters().get("duration-seconds");long ticks=seconds==null?Math.max(20,definition.cooldown().toMillis()/50):seconds.movePointRight(1).longValue();return (int)Math.max(1,Math.min(Integer.MAX_VALUE,ticks));}
|
||||
private boolean healBoss(Mob boss,double requested){var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);if(maximum==null||!Double.isFinite(requested)||requested<=0)return false;double accepted=Math.min(requested,maximum.getValue()-boss.getHealth());if(accepted<=0)return false;var event=new EntityRegainHealthEvent(boss,accepted,EntityRegainHealthEvent.RegainReason.CUSTOM);plugin.getServer().getPluginManager().callEvent(event);if(event.isCancelled()||!Double.isFinite(event.getAmount())||event.getAmount()<=0)return false;double before=boss.getHealth();boss.setHealth(Math.min(maximum.getValue(),before+event.getAmount()));return boss.getHealth()>before;}
|
||||
private boolean healBoss(Mob boss,double requested){return healBoss(boss,requested,null);}
|
||||
private boolean healBoss(Mob boss,double requested,games.dmg.fantasystick.events.CharacterBossEventBridge.Facts passiveFacts){var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);if(maximum==null||!Double.isFinite(requested)||requested<=0)return false;double initial=boss.getHealth();double accepted=Math.min(requested,maximum.getValue()-initial);if(accepted<=0)return false;var event=new EntityRegainHealthEvent(boss,accepted,EntityRegainHealthEvent.RegainReason.CUSTOM);plugin.getServer().getPluginManager().callEvent(event);if(event.isCancelled()||!Double.isFinite(event.getAmount())||event.getAmount()<=0){observeCharacterHealing(boss,event,requested,initial,initial,passiveFacts);return false;}double before=boss.getHealth();boss.setHealth(Math.min(maximum.getValue(),before+event.getAmount()));double after=boss.getHealth();observeCharacterHealing(boss,event,requested,before,after,passiveFacts);return after>before;}
|
||||
private static List<Player> abilityVictims(AbilityDefinition definition,Mob boss,Player target,List<Player> eligible,double range){if(definition.id().equals("chain_lightning")){var result=new ArrayList<Player>();result.add(target);int maximum=1+definition.parameters().getOrDefault("additional-targets",java.math.BigDecimal.ZERO).intValue();double jump=definition.parameters().getOrDefault("jump-range",java.math.BigDecimal.valueOf(range)).doubleValue();Player current=target;while(result.size()<maximum){var origin=current;var next=eligible.stream().filter(player->!result.contains(player)&&origin.hasLineOfSight(player)&&origin.getLocation().distanceSquared(player.getLocation())<=jump*jump).min(Comparator.comparingDouble((Player player)->origin.getLocation().distanceSquared(player.getLocation())).thenComparing(player->player.getUniqueId().toString())).orElse(null);if(next==null)break;result.add(next);current=next;}return List.copyOf(result);}double radius=definition.parameters().getOrDefault("radius",java.math.BigDecimal.valueOf(range)).doubleValue();var center=definition.id().equals("meteor")?target:boss;return definition.targetPolicy()==AbilityDefinition.TargetPolicy.AREA?eligible.stream().filter(player->center.hasLineOfSight(player)&¢er.getLocation().distanceSquared(player.getLocation())<=radius*radius).toList():List.of(target);}
|
||||
private void applyMobEffects(LivingEntity mob,List<DungeonState.Effect> effects){for(var effect:effects)if(effect.target()!=DungeonState.EffectTarget.PLAYERS){var type=effectType(effect.effect());if(type!=null)mob.addPotionEffect(new PotionEffect(type,PotionEffect.INFINITE_DURATION,effect.level()-1,false,effect.particles()));}}
|
||||
private final Set<String> observedEffectKeys=new HashSet<>();
|
||||
@@ -149,10 +171,22 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
private void contain(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;for(var entity:safe(world.getEntities()))if(managed(entity)&&dungeon.id().equals(id(entity,dungeonKey))&&room.id().equals(id(entity,roomKey))&&!inside(entity.getLocation(),dungeon,room)){relocating.add(entity);try{entity.teleport(new Location(world,(room.bounds().minX()+room.bounds().maxX()+1)/2d,room.bounds().minY()+1,(room.bounds().minZ()+room.bounds().maxZ()+1)/2d));if(entity instanceof LivingEntity living)living.setVelocity(new org.bukkit.util.Vector());}finally{relocating.remove(entity);}}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(managedEnemy(event.getEntity())){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),id(event.getEntity(),dungeonKey),id(event.getEntity(),roomKey))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID dungeon=id(by.getDamager(),dungeonKey),room=id(by.getDamager(),roomKey);if(!(event.getEntity() instanceof Player player)||dungeon==null||room==null||eligible(player,dungeon,room)==null)event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void accepted(EntityDamageByEntityEvent event){if(!managedEnemy(event.getEntity())||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;var player=source(event);UUID dungeon=id(event.getEntity(),dungeonKey),room=id(event.getEntity(),roomKey);var quest=player==null||dungeon==null||room==null?null:eligible(player,dungeon,room);var attempt=room==null?null:attempts.get(new AttemptKey(dungeon,room));if(quest!=null&&attempt!=null){attempt.represented.add(quest.id());attempt.threat.merge(player.getUniqueId(),event.getFinalDamage(),Double::sum);attempt.lastCombat=System.nanoTime();attempt.cooldowns.remove("recovery");}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void bossAccepted(EntityDamageByEntityEvent event){if(!(event.getDamager() instanceof Mob boss)||!managedEnemy(boss)||!(event.getEntity() instanceof Player player)||event.getFinalDamage()<=0)return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null)return;if(custody.character().passives().contains("stick_knockback")&&passives.find("stick_knockback").isPresent()){double radians=Math.toRadians(boss.getLocation().getYaw()),x=Math.sin(radians),z=-Math.cos(radians);if(Double.isFinite(x)&&Double.isFinite(z))player.knockback(1,x,z);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void bossAccepted(EntityDamageByEntityEvent event){if(!(event.getDamager() instanceof Mob boss)||!managedEnemy(boss)||!(event.getEntity() instanceof Player player)||event.getFinalDamage()<=0)return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null)return;var observation=characterMeleeFacts(boss,player,event);observeCharacterMeleeDamage(observation,event);if(custody.character().passives().contains("stick_knockback")&&passives.find("stick_knockback").isPresent()){double radians=Math.toRadians(boss.getLocation().getYaw()),x=Math.sin(radians),z=-Math.cos(radians);if(Double.isFinite(x)&&Double.isFinite(z)){var passive=characterMeleePassiveFacts(observation,boss,player,x,z);org.bukkit.util.Vector before=null;try{before=player.getVelocity().clone();}catch(RuntimeException unavailable){/* Optional observation only. */}var previous=characterMeleeKnockback.get();var calls=new ArrayList<NativeKnockback>();characterMeleeKnockback.set(calls);try{player.knockback(1,x,z);}finally{if(previous==null)characterMeleeKnockback.remove();else characterMeleeKnockback.set(previous);}observeCharacterMeleeKnockback(passive,player,before,calls);}}}
|
||||
private games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterMeleeFacts(Mob boss,Player player,EntityDamageByEntityEvent event){
|
||||
try{var events=characterBossEvents;var original=characterBossShellFacts.get(boss.getUniqueId());if(events==null||original==null)return null;var data=new LinkedHashMap<String,Object>();data.put("source_location",nativeLocation(boss.getLocation()));data.putAll(characterTargetData(player));data.put("damage_source",event.getCause()==EntityDamageEvent.DamageCause.ENTITY_ATTACK?"native_melee":"native_entity_damage");data.put("damage_cause",event.getCause().name());var cast=characterCasts.get().peek();if(cast==null)characterCasts.remove();else if(cast.shell.equals(boss.getUniqueId()))data.put("cast_id",cast.facts.request().action().toString());return events.transition(original,data);}catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private void observeCharacterMeleeDamage(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,EntityDamageByEntityEvent event){
|
||||
try{if(facts==null||characterBossEvents==null||facts.data().containsKey("cast_id"))return;nextTick.accept(()->{try{double amount=event.getFinalDamage();var data=new LinkedHashMap<String,Object>();data.put("outcome",event.isCancelled()?"cancelled":Double.isFinite(amount)&&amount>0?"accepted":"no_op");if(Double.isFinite(amount))data.put("final_damage",amount);else data.put("final_damage_status","non_finite_unquantified");data.put("native_cancelled",event.isCancelled());data.put("causation","original_native_character_shell_damage_event");data.put("confirmation_quality","final_native_event_decision_not_health_setter_proof");characterBossEvents.damage(facts,data);}catch(RuntimeException unavailable){/* Native-thread confirmation only; never gameplay authority. */}});}catch(RuntimeException unavailable){/* Optional metadata never affects original native damage. */}
|
||||
}
|
||||
private games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterMeleePassiveFacts(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts melee,Mob boss,Player player,double x,double z){
|
||||
try{if(melee==null||characterBossEvents==null)return null;return characterBossEvents.transition(melee,Map.of("passive_id","stick_knockback","melee_damage_operation_id",melee.request().action().toString(),"knockback_strength",1d,"direction_x",x,"direction_z",z,"target_location",nativeLocation(player.getLocation()),"source_location",nativeLocation(boss.getLocation())));}catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private void observeCharacterMeleeKnockback(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,Player player,org.bukkit.util.Vector before,List<NativeKnockback> calls){
|
||||
try{if(facts==null||before==null||characterBossEvents==null||calls.size()!=1)return;var nativeEvent=calls.getFirst().event();var after=player.getVelocity();if(nativeEvent.getEntity()!=player||nativeEvent.isCancelled()||after==null||!Double.isFinite(after.getX())||!Double.isFinite(after.getY())||!Double.isFinite(after.getZ())||after.equals(before))return;var data=new LinkedHashMap<String,Object>();data.put("outcome","accepted");data.put("effect_type","native_knockback");data.put("application_status","native_event_and_velocity_confirmed");data.put("velocity_before",Map.of("x",before.getX(),"y",before.getY(),"z",before.getZ()));data.put("velocity_after",Map.of("x",after.getX(),"y",after.getY(),"z",after.getZ()));data.put("native_final_cancelled",false);data.put("causation","original_native_character_shell_damage_passive");characterBossEvents.effectApplied(facts,facts.request().action(),data);}catch(RuntimeException unavailable){/* Native effect/velocity remain authoritative. */}
|
||||
}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void bossKill(org.bukkit.event.entity.PlayerDeathEvent event){if(!(event.getEntity().getLastDamageCause() instanceof EntityDamageByEntityEvent damage)||!(damage.getDamager() instanceof Mob boss)||!managedEnemy(boss))return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null||!custody.character().passives().contains("health_steal")||passives.find("health_steal").isEmpty()||eligible(event.getPlayer(),id(boss,dungeonKey),id(boss,roomKey))==null)return;healBoss(boss,Math.min(Double.MAX_VALUE,healthStealHealing.get().doubleValue()));}
|
||||
public void accept(AbilityReport report){if(report.type()==AbilityReport.Type.DAMAGE)return;var active=sessions.activeCharacter(report.sourceCharacter()).filter(sessions::current).orElse(null);if(active==null)return;var player=plugin.getServer().getPlayer(active.player());if(player==null)return;double value=report.amount().doubleValue();if(!Double.isFinite(value)||value<=0)return;for(var entry:attempts.entrySet())if(eligible(player,entry.getKey().dungeon(),entry.getKey().room())!=null)entry.getValue().threat.merge(player.getUniqueId(),value*.5d,Double::sum);}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){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")));}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){var origin=encounterOrigin(event.getEntity());if(origin.isEmpty())return;endNativeEffects(event.getEntity(),"accepted_native_death");observeCharacterShellEffectsEnded(event.getEntity(),"accepted_native_death");captureBossDefeat(attempt,event.getEntity(),room);if(room.boss().character()==null)attempt.deathObservation=nativeFacts(dungeon,room,attempt,event.getEntity(),null,true,nativeTrigger(event),"death_location");else if(attempt.characterDeathObservation==null){attempt.characterDeathObservation=characterShellTransition(attempt,event.getEntity(),"death_location",nativeTrigger(event));characterBossShellFacts.remove(event.getEntity().getUniqueId());}pendingBosses.putIfAbsent(origin.orElseThrow().source(),event.getEntity().getUniqueId());attempt.boss=null;defeatingDungeons.add(dungeonId);cleanupDungeon(dungeonId);clearRoom(dungeon,room,attempt,true);return;}UUID slot=id(event.getEntity(),slotKey);var origin=encounterOrigin(event.getEntity());if(slot==null||origin.isEmpty())return;endNativeEffects(event.getEntity(),"accepted_native_death");attempt.entities.remove(event.getEntity().getUniqueId());pendingKills.putIfAbsent(origin.orElseThrow().source(),new PendingKill(event.getEntity().getUniqueId(),dungeonId,roomId,generation,slot,nativeFacts(dungeon,room,attempt,event.getEntity(),slot,false,nativeTrigger(event),"death_location")));}
|
||||
private void captureBossDefeat(Attempt attempt,LivingEntity entity,DungeonState.Room room){
|
||||
if(attempt.defeatedBoss!=null)return;
|
||||
attempt.defeatedBoss=entity.getUniqueId();var location=entity.getLocation();
|
||||
@@ -183,7 +217,7 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
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){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(boss){observeCharacterBossDefeat(attempt.characterDeathObservation);observe("dungeon.entity.defeated",attempt.deathObservation,Map.of("reason","native_boss_completion_committed","settlement_status","acknowledged_boss_completion","defeated_slot",false));observe("dungeon.run.closed",attempt.deathObservation==null?attempt.observation:attempt.deathObservation,Map.of("reason","boss_completion_global_close","run_defeated",run.defeated(),"reward_owner","US-085","completion_status","acknowledged_global_close"));cleanupDungeon(dungeon.id());
|
||||
if(auditEvents!=null)try{auditEvents.completed(run);}catch(RuntimeException error){
|
||||
plugin.getLogger().log(java.util.logging.Level.WARNING,
|
||||
"Committed dungeon completion audit publication failed",error);
|
||||
@@ -252,12 +286,61 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
private static Map<String,Object> nativeLocation(Location at){if(at==null||at.getWorld()==null)return Map.of("location_status","unavailable");return Map.of("world_id",at.getWorld().getUID().toString(),"world_name",Objects.toString(at.getWorld().getName(),"unavailable"),"x",at.getX(),"y",at.getY(),"z",at.getZ(),"yaw",(double)at.getYaw(),"pitch",(double)at.getPitch(),"captured_at",java.time.Instant.now().toString(),"quality","native_original");}
|
||||
private games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts nativeFacts(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,Entity entity,UUID slot,boolean boss,Player trigger,String locationField){try{if(ownerEvents==null)return null;var data=new LinkedHashMap<String,Object>();data.put("native_entity_id",entity.getUniqueId().toString());data.put("entity_type",entity.getType().name());data.put("entity_role",boss?"native_boss":"ordinary_slot");data.put("entity_name",boss?room.boss().name():room.mobs().stream().filter(value->value.id().equals(slot)).map(DungeonState.Spawn::name).findFirst().orElse("unavailable"));if(slot!=null)data.put("spawn_slot_id",slot.toString());data.put(locationField,nativeLocation(entity.getLocation()));data.put("managed_source_id",UUID.nameUUIDFromBytes((dungeon.id()+":"+room.id()+":"+attempt.generation+":"+(slot==null?"boss":slot.toString())).getBytes(StandardCharsets.UTF_8)).toString());data.put("damage_action_capture_quality",locationField.equals("death_location")?"unavailable_not_in_native_death":"not_applicable_native_observation");if(attempt.observation!=null)data.put("activation_operation_id",attempt.observation.operation().toString());if(trigger==null&&!locationField.equals("death_location")&&attempt.observation!=null){var original=new LinkedHashMap<>(attempt.observation.data());original.putAll(data);var actor=attempt.observation.actor();if(actor!=null&&actor.context()!=null)original.put("triggering_player_id",actor.context().playerId().toString());return new games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts(UUID.randomUUID(),actor,original);}data.putAll(trigger==null?Map.of("trigger_quest_status","unavailable"):questData(eligible(trigger,dungeon.id())));if(trigger!=null)data.put("triggering_player_id",trigger.getUniqueId().toString());return facts(dungeon,room,attempt.generation,trigger,data);}catch(RuntimeException unavailable){return null;}}
|
||||
private void observeSpawn(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,Entity entity,UUID slot,boolean boss){try{if(ownerEvents==null||entity==null||!entity.isValid()||!managedEnemy(entity)||!attempt.generation.equals(id(entity,generationKey))||!loaded(entity.getWorld(),new DungeonState.Position(entity.getLocation().getX(),entity.getLocation().getY(),entity.getLocation().getZ(),entity.getLocation().getYaw(),entity.getLocation().getPitch())))return;var observation=nativeFacts(dungeon,room,attempt,entity,slot,boss,null,"spawn_location");observe("dungeon.entity.spawned",observation,Map.of("materialization_status","loaded_valid_registered_entity","attempt_capture_quality","native_transient_not_persisted"));observeNativeEffects(room,entity,observation);}catch(RuntimeException unavailable){/* Observation cannot undo returned native calls. */}}
|
||||
private void observeCharacterBossSpawn(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,Entity entity){try{var events=characterBossEvents;if(events==null||entity==null||!entity.isValid()||!managedEnemy(entity)||!attempt.generation.equals(id(entity,generationKey)))return;var at=entity.getLocation();if(at==null||!loaded(entity.getWorld(),new DungeonState.Position(at.getX(),at.getY(),at.getZ(),at.getYaw(),at.getPitch())))return;var facts=events.runtime(dungeon,room,attempt.generation,entity.getUniqueId(),entity.getType().name(),nativeLocation(at));characterBossShellFacts.put(entity.getUniqueId(),facts);events.spawned(facts);if(entity instanceof LivingEntity living)observeCharacterPassives(room,living,facts,at);}catch(RuntimeException unavailable){/* Optional confirmation cannot undo or interrupt character-boss native materialization. */}}
|
||||
private void observeCharacterPassives(DungeonState.Room room,LivingEntity shell,games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,Location at){
|
||||
try{var audit=characterBossEffects;if(audit==null)return;for(var id:room.boss().character().character().passives()){
|
||||
PotionEffectType type=switch(id){case "speed_i"->PotionEffectType.SPEED;case "low_light_vision"->PotionEffectType.NIGHT_VISION;default->null;};
|
||||
try{if(type!=null&&passives.find(id).isPresent())audit.passive(facts,shell,id,new PotionEffect(type,PotionEffect.INFINITE_DURATION,0,false,false),nativeLocation(at));}catch(RuntimeException unavailable){/* One optional layer cannot hide another independent equipped passive. */}
|
||||
}}catch(RuntimeException unavailable){/* Native spawn and passive state remain authoritative. */}
|
||||
}
|
||||
private games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterShellTransition(Attempt attempt,Entity entity,String locationField,Player trigger){
|
||||
try{var events=characterBossEvents;var original=characterBossShellFacts.get(entity.getUniqueId());if(events==null||original==null||!entity.isValid()||!managedEnemy(entity)||!entity.getUniqueId().equals(attempt.boss)||!attempt.generation.equals(id(entity,generationKey)))return null;var at=entity.getLocation();var data=new LinkedHashMap<String,Object>();data.put(locationField,nativeLocation(at));data.put("damage_action_capture_quality",locationField.equals("death_location")?"unavailable_not_in_native_death":"not_applicable_native_observation");if(trigger!=null){data.put("triggering_player_id",trigger.getUniqueId().toString());data.put("triggering_player_location",nativeLocation(trigger.getLocation()));data.putAll(questData(eligible(trigger,attempt.dungeon)));}return events.transition(original,data);}catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private Map<String,Object> characterTargetData(Player target){var data=new LinkedHashMap<String,Object>();data.put("target_kind","accepted_fantasy_player");data.put("target_player_id",target.getUniqueId().toString());data.put("target_location",nativeLocation(target.getLocation()));sessions.active(target.getUniqueId()).ifPresent(active->data.put("target_character_id",active.character().toString()));return data;}
|
||||
private CharacterCast beginCharacterCast(Mob boss,Player target,AbilityDefinition definition,int slot){
|
||||
try{var events=characterBossEvents;var original=characterBossShellFacts.get(boss.getUniqueId());if(events==null||original==null)return null;var data=new LinkedHashMap<String,Object>();data.put("ability_id",definition.id());data.put("ability_category",definition.category().name());data.put("ability_slot_index",Integer.toString(slot));data.put("cooldown_millis",Long.toString(definition.cooldown().toMillis()));data.put("cooldown_status","ready_at_original_native_attempt");data.put("source_location",nativeLocation(boss.getLocation()));if(definition.category()==AbilityDefinition.Category.HEAL||definition.category()==AbilityDefinition.Category.BUF||definition.id().equals("purify")){data.put("target_kind","native_character_shell");data.put("target_entity_id",boss.getUniqueId().toString());data.put("target_location",nativeLocation(boss.getLocation()));}else data.putAll(characterTargetData(target));var facts=events.cast(original,data);var observation=new CharacterCast(facts,boss.getUniqueId());events.casting(facts);characterCasts.get().push(observation);return observation;}catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void characterAbilityDamage(EntityDamageByEntityEvent event){
|
||||
try{var stack=characterCasts.get();var cast=stack.peek();if(cast==null){characterCasts.remove();return;}if(characterBossEvents==null||!cast.shell.equals(event.getDamager().getUniqueId())||!(event.getEntity() instanceof Player player))return;cast.damage.add(new ObservedCharacterDamage(characterBossEvents.transition(cast.facts,characterTargetData(player)),event));}catch(RuntimeException unavailable){/* Retain no gameplay authority or asynchronous native event reference. */}
|
||||
}
|
||||
private boolean characterPotion(LivingEntity target,PotionEffect requested,boolean force){
|
||||
CharacterPotion call=null;
|
||||
try{var cast=characterCasts.get().peek();if(cast==null)characterCasts.remove();if(cast!=null&&characterBossEffects!=null&&characterBossEvents!=null){var data=new LinkedHashMap<String,Object>();data.put("target_entity_id",target.getUniqueId().toString());data.put("target_location",nativeLocation(target.getLocation()));if(target instanceof Player player)data.putAll(characterTargetData(player));else data.put("target_kind","native_character_shell");data.put("native_call_capture_quality","exact_synchronous_target_and_requested_effect");call=new CharacterPotion(cast,characterBossEvents.transition(cast.facts,data),target,requested,CharacterBossEffectAudit.layer(target,requested.getType()),new ArrayList<>());}}catch(RuntimeException unavailable){/* New metadata cannot suppress the original add call. */}
|
||||
if(call!=null)characterPotions.get().push(call);
|
||||
try{boolean returned=target.addPotionEffect(requested,force);try{if(call!=null)call.cast().effects.add(new CharacterBossEffectAudit.NativeCall(call.facts(),target,requested,call.prior(),returned,call.nativeEvents()));}catch(RuntimeException unavailable){/* Audit never changes the returned native decision. */}return returned;}
|
||||
finally{if(call!=null){var stack=characterPotions.get();stack.remove(call);if(stack.isEmpty())characterPotions.remove();}}
|
||||
}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void characterAbilityPotion(EntityPotionEffectEvent event){
|
||||
try{var audit=characterBossEffects;if(audit==null)return;if(event.getAction()==EntityPotionEffectEvent.Action.REMOVED||event.getAction()==EntityPotionEffectEvent.Action.CHANGED)audit.removal(event);
|
||||
var call=characterPotions.get().peek();if(call==null){characterPotions.remove();return;}
|
||||
if(event.getCause()!=EntityPotionEffectEvent.Cause.PLUGIN||event.getEntity()!=call.target()||!call.requested().equals(event.getNewEffect())
|
||||
||event.getSource()!=null&&!call.cast().shell.equals(event.getSource().getUniqueId()))return;
|
||||
call.nativeEvents().add(event);
|
||||
}catch(RuntimeException unavailable){/* Native potion decisions remain authoritative. */}
|
||||
}
|
||||
private games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterPassiveHealingFacts(Mob boss,DungeonState.CharacterBoss custody,String source,java.math.BigInteger base,java.math.BigInteger bonus){
|
||||
try{var events=characterBossEvents;var original=characterBossShellFacts.get(boss.getUniqueId());if(events==null||original==null)return null;var at=nativeLocation(boss.getLocation());var data=new LinkedHashMap<String,Object>();data.put("healing_source",source);data.put("recovery_base_amount",base.toString());data.put("recovery_bonus_amount",bonus.toString());var ids=new ArrayList<String>();if(custody.character().passives().contains("quicker_regeneration_i")){ids.add("quicker_regeneration_i");if(custody.character().passives().contains("quicker_regeneration_ii"))ids.add("quicker_regeneration_ii");}data.put("recovery_bonus_passive_ids",List.copyOf(ids));data.put("bonus_component_amounts_status","unavailable_from_combined_native_bonus_port");data.put("source_location",at);data.put("target_location",at);data.put("target_entity_id",boss.getUniqueId().toString());data.put("target_kind","native_character_shell");if(custody.character().passives().contains("quicker_regeneration_i")&&passives.find("quicker_regeneration_i").isPresent())data.put("passive_id","quicker_regeneration_i");return events.transition(original,data);}catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private void observeCharacterHealing(Mob boss,EntityRegainHealthEvent event,double requested,double before,double after,games.dmg.fantasystick.events.CharacterBossEventBridge.Facts passiveFacts){
|
||||
try{var cast=passiveFacts==null?characterCasts.get().peek():null;if(passiveFacts==null&&cast==null){characterCasts.remove();return;}if(characterBossEvents==null||cast!=null&&!cast.shell.equals(boss.getUniqueId()))return;var facts=passiveFacts==null?cast.facts:passiveFacts;double applied=Math.max(0,after-before);var outcome=event.isCancelled()?"cancelled":applied>0?"accepted":"no_op";if(cast!=null)cast.nativeOutcome=outcome;var data=new LinkedHashMap<String,Object>();data.put("outcome",outcome);data.put("requested_amount",requested);if(Double.isFinite(event.getAmount()))data.put("native_event_amount",event.getAmount());else data.put("native_event_amount_status","non_finite_refused");data.put("applied_amount",applied);data.put("health_before",before);data.put("health_after",after);data.put("native_cancelled",event.isCancelled());data.put("causation","original_character_boss_native_regain_event");characterBossEvents.healed(characterBossEvents.transition(facts,Map.of("target_entity_id",boss.getUniqueId().toString(),"target_kind","native_character_shell")),data);}catch(RuntimeException unavailable){/* Actual native event/health setter remains authority. */}
|
||||
}
|
||||
private void completeCharacterCast(CharacterCast cast,boolean cooldown,AbilityDefinition.Category category){
|
||||
try{if(cast==null||characterBossEvents==null)return;String outcome=cast.nativeOutcome;for(var observation:cast.damage){var event=observation.event();double amount=event.getFinalDamage();String result=event.isCancelled()?"cancelled":Double.isFinite(amount)&&amount>0?"accepted":"no_op";var data=new LinkedHashMap<String,Object>();data.put("outcome",result);if(Double.isFinite(amount))data.put("final_damage",amount);else data.put("final_damage_status","non_finite_refused");if(Double.isFinite(event.getDamage()))data.put("native_requested_damage",event.getDamage());data.put("native_cancelled",event.isCancelled());data.put("damage_cause",event.getCause().name());data.put("causation","original_character_boss_native_damage_event");characterBossEvents.damage(observation.facts(),data);if(outcome==null||result.equals("accepted"))outcome=result;}String effectOutcome=characterBossEffects==null?null:characterBossEffects.resolve(cast.effects);if(effectOutcome!=null){var details=new LinkedHashMap<String,Object>();details.put("native_effect_outcome",effectOutcome);var original=new LinkedHashMap<>(cast.facts.data());original.putAll(details);var resolved=new games.dmg.fantasystick.events.CharacterBossEventBridge.Facts(cast.facts.request(),cast.facts.custody(),original);if(outcome==null)outcome=!cooldown?"no_op":category==AbilityDefinition.Category.ATK?"native_damage_unobserved":"native_call_returned";characterBossEvents.resolved(resolved,cooldown,outcome);}else{if(outcome==null)outcome=!cooldown?"no_op":category==AbilityDefinition.Category.ATK?"native_damage_unobserved":"native_call_returned";characterBossEvents.resolved(cast.facts,cooldown,outcome);}}catch(RuntimeException unavailable){/* Observation cannot commit/refund/change a cooldown. */}
|
||||
}
|
||||
private void endCharacterCast(CharacterCast cast){if(cast==null)return;var stack=characterCasts.get();stack.remove(cast);if(stack.isEmpty())characterCasts.remove();}
|
||||
private games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterTargetFacts(Mob boss,Player target,LivingEntity previous,Attempt attempt){
|
||||
try{var events=characterBossEvents;var original=characterBossShellFacts.get(boss.getUniqueId());if(events==null||original==null)return null;var data=new LinkedHashMap<String,Object>();data.put("source_location",nativeLocation(boss.getLocation()));data.put("target_player_id",target.getUniqueId().toString());data.put("target_location",nativeLocation(target.getLocation()));data.put("target_kind","accepted_fantasy_player");data.put("accepted_threat",attempt.threat.getOrDefault(target.getUniqueId(),0d));data.put("reason",previous==null?"highest_accepted_threat":"fixation_invalidated_retarget");sessions.active(target.getUniqueId()).ifPresent(active->data.put("target_character_id",active.character().toString()));if(previous instanceof Player player)data.put("previous_target_player_id",player.getUniqueId().toString());data.putAll(questData(eligible(target,attempt.dungeon)));return events.transition(original,data);}catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private void observeCharacterTarget(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,Mob boss,Player target){try{if(characterBossEvents!=null&&facts!=null&&boss.getTarget()==target)characterBossEvents.targetChanged(facts);}catch(RuntimeException unavailable){/* Optional confirmation cannot interrupt native targeting or subsequent ability execution. */}}
|
||||
private void observeCharacterBossDefeat(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts){try{if(characterBossEvents!=null&&facts!=null)characterBossEvents.defeated(facts);}catch(RuntimeException unavailable){/* Optional publication cannot affect acknowledged native settlement. */}}
|
||||
private void observeCharacterShellEffectsEnded(Entity entity,String reason){var audit=characterBossEffects;if(audit==null)return;boolean confirmed;try{confirmed="accepted_native_death".equals(reason)||!entity.isValid();}catch(RuntimeException unavailable){try{audit.shellEndUnconfirmed(entity,reason,"remove_returned_validity_unavailable");}catch(RuntimeException optional){/* No native layer mutation or cleanup authority. */}return;}try{if(confirmed)audit.shellEnded(entity,reason);else audit.shellEndUnconfirmed(entity,reason,"remove_returned_still_valid");}catch(RuntimeException unavailable){/* No native layer mutation or cleanup authority. */}}
|
||||
private void observeCharacterBossRemoval(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,Entity entity,String reason){try{if(characterBossEvents==null||facts==null)return;String status;try{status=entity.isValid()?"remove_returned_still_valid":"native_no_longer_valid";}catch(RuntimeException unavailable){status="remove_returned_validity_unavailable";}characterBossEvents.removed(facts,reason,status);}catch(RuntimeException unavailable){/* Optional publication cannot affect native cleanup. */}}
|
||||
private final Map<UUID,List<games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts>> observedNativeEffects=new HashMap<>();
|
||||
private void observeNativeEffects(DungeonState.Room room,Entity entity,games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts spawn){try{if(spawn==null||!(entity instanceof LivingEntity living)||observedNativeEffects.containsKey(entity.getUniqueId()))return;var layers=new ArrayList<games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts>();for(var definition:room.effects()){if(definition.target()==DungeonState.EffectTarget.PLAYERS)continue;var type=effectType(definition.effect());var current=type==null?null:effect(living,type);if(current==null||current.getAmplifier()!=definition.level()-1)continue;var data=new LinkedHashMap<>(spawn.data());data.put("effect_id",definition.id().toString());data.put("effect",definition.effect());data.put("level",Integer.toString(definition.level()));data.put("particles",definition.particles());data.put("target_kind","managed_mob");data.put("action_location",data.get("spawn_location"));var operation=UUID.nameUUIDFromBytes((spawn.operation()+":"+definition.id()).getBytes(StandardCharsets.UTF_8));var layer=new games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts(operation,spawn.actor(),data);layers.add(layer);observe("effect.applied",layer,Map.of("application_status","loaded_valid_native_layer_confirmed","reason","configured_room_mob_effect"));}if(!layers.isEmpty())observedNativeEffects.put(entity.getUniqueId(),List.copyOf(layers));}catch(RuntimeException unavailable){/* Native application and ownership never depend on observation. */}}
|
||||
private void endNativeEffects(Entity entity,String reason){var layers=observedNativeEffects.remove(entity.getUniqueId());if(layers!=null)for(var layer:layers)observe("effect.ended",layer,Map.of("reason",reason,"cleanup_status","native_entity_lifecycle_ended","reward_status","no_effect_reward"));}
|
||||
private games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts effectFacts(UUID dungeon,DungeonState.Room room,Player player,DungeonState.Effect effect){try{if(ownerEvents==null)return null;var definition=dungeons.find(dungeon).orElse(null);var quest=eligible(player,dungeon);if(definition==null||quest==null)return null;var data=new LinkedHashMap<String,Object>(questData(quest));data.put("effect_id",effect.id().toString());data.put("effect",effect.effect());data.put("target_kind","player");data.put("target_player_id",player.getUniqueId().toString());data.put("level",Integer.toString(effect.level()));data.put("particles",effect.particles());data.put("action_location",nativeLocation(player.getLocation()));return facts(definition,room,quest.catalog(),player,data);}catch(RuntimeException unavailable){return null;}}
|
||||
private void cleanupAttempt(AttemptKey key){cleanupAttempt(key,"runtime_room_cleanup");}
|
||||
private void cleanupAttempt(AttemptKey key,String reason){var attempt=attempts.remove(key);if(attempt==null)return;for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity)&&key.dungeon().equals(id(entity,dungeonKey))&&key.room().equals(id(entity,roomKey))){var observation=removalFacts(key,attempt,entity);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 void cleanupAttempt(AttemptKey key,String reason){var attempt=attempts.remove(key);if(attempt==null)return;for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity)&&key.dungeon().equals(id(entity,dungeonKey))&&key.room().equals(id(entity,roomKey))){var observation=removalFacts(key,attempt,entity);var characterObservation=characterShellTransition(attempt,entity,"removal_location",null);entity.remove();observeCharacterBossRemoval(characterObservation,entity,reason);observeCharacterShellEffectsEnded(entity,reason);characterBossShellFacts.remove(entity.getUniqueId());endNativeEffects(entity,reason);if(observation!=null){String status;try{status=entity.isValid()?"remove_returned_still_valid":"native_no_longer_valid";}catch(RuntimeException unavailable){status="remove_returned_validity_unavailable";}observe("dungeon.entity.removed",observation,Map.of("reason",reason,"reward_status","non_reward_cleanup","cleanup_status",status));}}attempt.entities.clear();attempt.boss=null;}
|
||||
private games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts removalFacts(AttemptKey key,Attempt attempt,Entity entity){try{if(ownerEvents==null||!entity.isValid()||!managedEnemy(entity)||!attempt.generation.equals(id(entity,generationKey)))return null;var dungeon=dungeons.find(key.dungeon()).orElse(null);var room=dungeons.room(key.dungeon(),key.room()).orElse(null);if(dungeon==null||room==null)return null;boolean boss=entity.getUniqueId().equals(attempt.boss);UUID slot=attempt.entities.get(entity.getUniqueId());if(!boss&&slot==null||boss&&(room.boss()==null||room.boss().character()!=null))return null;return nativeFacts(dungeon,room,attempt,entity,slot,boss,null,"removal_location");}catch(RuntimeException unavailable){return null;}}
|
||||
private void cleanupDungeon(UUID dungeon){cleanupOwnedDungeon(dungeon);for(var key:new ArrayList<>(attempts.keySet()))if(key.dungeon().equals(dungeon))cleanupAttempt(key);for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity)&&dungeon.equals(id(entity,dungeonKey)))entity.remove();}
|
||||
private void cleanupRuntime(){for(var key:new ArrayList<>(attempts.keySet()))cleanupAttempt(key);cleanupOwnedEffects(Set.of());completing.clear();defeatingDungeons.clear();startingRuns.clear();}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.dungeon.DungeonState;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.command.CommandSender;
|
||||
|
||||
/** Original character-boss observations only; no character, inventory or encounter authority. */
|
||||
public final class CharacterBossEventBridge implements AutoCloseable {
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Function<CommandSender,CampEventBridge.Actor> capture;
|
||||
public CharacterBossEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,Path data,
|
||||
Function<CommandSender,CampEventBridge.Actor> capture,Consumer<Throwable> failures){
|
||||
this.capture=java.util.Objects.requireNonNull(capture);
|
||||
outbox=new FeatureEventOutbox(data,"character-boss-event-intents",Set.of("dungeon.character_boss.assigned","dungeon.character_boss.recovered","action.resolved","dungeon.entity.spawned","dungeon.entity.removed","dungeon.entity.defeated","dungeon.character_boss.target_changed","ability.cast","ability.resolved","combat.damage","combat.healed","effect.applied","effect.ended"),sink,failures);
|
||||
}
|
||||
public record Request(UUID action,Instant observedAt,CampEventBridge.Actor actor){}
|
||||
public Request request(CommandSender sender){CampEventBridge.Actor actor;try{actor=capture.apply(sender);}catch(RuntimeException unavailable){actor=null;}if(actor==null)actor=unavailableActor(sender);return new Request(UUID.randomUUID(),Instant.now(),actor);}
|
||||
private static CampEventBridge.Actor unavailableActor(CommandSender sender){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_details_status","optional_capture_unavailable");data.put("location_status","unavailable");
|
||||
if(sender instanceof org.bukkit.entity.Player player){data.put("actor_kind","player");try{var id=player.getUniqueId();String name;try{name=player.getName();}catch(RuntimeException unavailable){name="unavailable";data.put("actor_name_status","unavailable");}if(name==null){name="unavailable";data.put("actor_name_status","unavailable");}return new CampEventBridge.Actor(new EventInput.Context(id,name,null,null,null,null),data);}catch(RuntimeException unavailable){data.put("actor_identity_status","unavailable");}}
|
||||
else data.put("actor_kind",sender instanceof org.bukkit.command.ConsoleCommandSender?"console":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"unavailable");
|
||||
return new CampEventBridge.Actor(null,data);
|
||||
}
|
||||
public record Facts(Request request,UUID custody,Map<String,Object> data){public Facts{data=Map.copyOf(data);}}
|
||||
public Facts custody(Request request,DungeonState.Dungeon definition,UUID room,DungeonState.CharacterBoss custody){
|
||||
var subject=custody.character();var data=new LinkedHashMap<String,Object>();data.put("dungeon_id",definition.id().toString());data.put("dungeon_name",definition.name());data.put("definition_revision",definition.revision().toString());data.put("room_id",room.toString());data.put("custody_operation_id",custody.operation().toString());data.put("subject_character_id",subject.id().toString());data.put("subject_character_name",subject.name());data.put("subject_character_revision",subject.revision().toString());data.put("subject_sheet_generation",subject.sheetGeneration().toString());data.put("subject_role","custody_template_not_active_actor");data.put("multiplier",custody.multiplier().toPlainString());data.put("prior_entity_type",custody.priorType().name());data.put("prior_entity_name",custody.priorName());var stats=new LinkedHashMap<String,Object>();custody.effectiveStats().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->stats.put(entry.getKey().name(),entry.getValue().toString()));data.put("multiplied_stats",Map.copyOf(stats));if(custody.recovery()!=null)data.put("recovery_operation_id",custody.recovery().operation().toString());return new Facts(request,custody.operation(),data);
|
||||
}
|
||||
public Facts assignment(Request request,DungeonDirectory.Publication publication,UUID dungeon,UUID room,DungeonState.CharacterBoss custody){
|
||||
var after=publication.after().dungeons().stream().filter(value->value.id().equals(dungeon)).findFirst().orElseThrow();var before=publication.before().dungeons().stream().filter(value->value.id().equals(dungeon)).findFirst().orElseThrow();var facts=custody(request,after,room,custody);var data=new LinkedHashMap<>(facts.data());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());var boss=before.rooms().stream().filter(value->value.id().equals(room)).findFirst().orElseThrow().boss();if(boss!=null)data.put("prior_spawn_location",position(before.world(),boss.position()));return new Facts(request,custody.operation(),data);
|
||||
}
|
||||
private static Map<String,Object> position(UUID world,DungeonState.Position at){return Map.of("world_id",world.toString(),"x",at.x(),"y",at.y(),"z",at.z(),"yaw",(double)at.yaw(),"pitch",(double)at.pitch());}
|
||||
/** Native shell subject and original immutable source facts, never an activating-player actor. */
|
||||
public Facts runtime(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,UUID shell,String type,Map<String,Object> location){
|
||||
var request=new Request(UUID.randomUUID(),Instant.now(),null);var facts=custody(request,dungeon,room.id(),room.boss().character());var data=new LinkedHashMap<>(facts.data());data.put("actor_kind","system");data.put("location_status","not_applicable");data.put("generation_id",generation.toString());data.put("catalog_id",generation.toString());data.put("native_entity_id",shell.toString());data.put("entity_type",type);data.put("entity_role","character_boss");data.put("entity_name",room.boss().name());data.put("spawn_location",Map.copyOf(location));data.put("managed_source_id",UUID.nameUUIDFromBytes((dungeon.id()+":"+room.id()+":"+generation+":boss").getBytes(StandardCharsets.UTF_8)).toString());data.put("run_id",UUID.nameUUIDFromBytes(("dungeon-run:"+dungeon.id()+":"+generation).getBytes(StandardCharsets.UTF_8)).toString());data.put("run_identity_quality","persisted_dungeon_generation_composite");return new Facts(request,facts.custody(),data);
|
||||
}
|
||||
public void spawned(Facts facts){emit("dungeon.entity.spawned",UUID.fromString(facts.data().get("native_entity_id").toString()),facts,Map.of("outcome","native_observed","materialization_status","loaded_valid_registered_entity","attempt_capture_quality","native_transient_not_persisted","causation","accepted_dungeon_native_spawn"));}
|
||||
/** Freeze a new original native transition while retaining the original shell's immutable source/subject. */
|
||||
public Facts transition(Facts original,Map<String,Object> details){
|
||||
var data=new LinkedHashMap<>(original.data());data.putIfAbsent("spawn_operation_id",original.request().action().toString());data.putAll(details);return new Facts(new Request(UUID.randomUUID(),Instant.now(),null),original.custody(),data);
|
||||
}
|
||||
public void removed(Facts facts,String reason,String status){emit("dungeon.entity.removed",UUID.fromString(facts.data().get("native_entity_id").toString()),facts,Map.of("reason",reason,"outcome","native_cleanup_observed","cleanup_status",status,"reward_status","non_reward_cleanup","causation","character_shell_native_cleanup"));}
|
||||
public void defeated(Facts facts){emit("dungeon.entity.defeated",UUID.fromString(facts.data().get("native_entity_id").toString()),facts,Map.of("reason","native_boss_completion_committed","outcome","acknowledged","settlement_status","acknowledged_boss_completion","reward_owner","US-085","causation","accepted_native_character_shell_death"));}
|
||||
public void targetChanged(Facts facts){emit("dungeon.character_boss.target_changed",facts.request().action(),facts,Map.of("outcome","accepted","target_status","native_target_confirmed","causation","accepted_character_shell_threat_target"));}
|
||||
public Facts cast(Facts original,Map<String,Object> details){var facts=transition(original,details);var data=new LinkedHashMap<>(facts.data());data.put("cast_id",facts.request().action().toString());return new Facts(facts.request(),facts.custody(),data);}
|
||||
public void casting(Facts facts){emit("ability.cast",facts.request().action(),facts,Map.of("outcome","attempted","causation","ready_equipped_character_boss_slot"));}
|
||||
public void resolved(Facts facts,boolean cooldown,String outcome){emit("ability.resolved",facts.request().action(),facts,Map.of("outcome",outcome,"cooldown_committed",cooldown,"causation","original_native_character_boss_cast"));}
|
||||
public void effectApplied(Facts facts,UUID application,Map<String,Object> details){emit("effect.applied",application,facts,details);}
|
||||
public void effectEnded(Facts facts,UUID application,Map<String,Object> details){emit("effect.ended",application,facts,details);}
|
||||
public void effectEndPending(Facts facts,UUID application,Map<String,Object> details){emit("action.resolved",application,facts,details);}
|
||||
public void damage(Facts facts,Map<String,Object> details){emit("combat.damage",facts.request().action(),facts,details);}
|
||||
public void healed(Facts facts,Map<String,Object> details){emit("combat.healed",facts.request().action(),facts,details);}
|
||||
/** Source acknowledgement alone is not physical consumption; no late sender/native lookup. */
|
||||
public void custodySaved(Facts facts){var phase=UUID.nameUUIDFromBytes((facts.custody()+":custody_saved").getBytes(StandardCharsets.UTF_8));emit("action.resolved",phase,facts,Map.of("phase","custody_saved","outcome","pending_native_consumption","custody_status","durably_assigned","sheet_status","consumption_not_acknowledged","causation","registered_assign_sheet"));}
|
||||
public void assigned(Facts facts){emit("dungeon.character_boss.assigned",facts.custody(),facts,Map.of("outcome","acknowledged","custody_status","durably_assigned","sheet_status","exact_original_consumed","causation","registered_assign_sheet"));}
|
||||
public void rolledBack(Facts facts,boolean acknowledged,DungeonDirectory.Publication publication,String reason){
|
||||
var phase=acknowledged?"assignment_cancelled":"assignment_rollback_pending";var data=new LinkedHashMap<String,Object>();data.put("phase",phase);data.put("outcome",acknowledged?"cancelled":"pending_rollback");data.put("reason",reason);data.put("sheet_status","consumption_not_acknowledged");data.put("custody_status",acknowledged?"assignment_rollback_acknowledged":"rollback_unacknowledged");data.put("rollback_publication_status",publication==null?"unavailable_not_acknowledged":"original_source_publication");data.put("causation","registered_assign_sheet");if(publication!=null){var dungeon=facts.data().get("dungeon_id").toString();publication.after().dungeons().stream().filter(value->value.id().toString().equals(dungeon)).findFirst().ifPresent(value->data.put("rollback_after_revision",value.revision().toString()));}var transition=UUID.nameUUIDFromBytes((facts.custody()+":"+phase+":"+facts.request().action()).getBytes(StandardCharsets.UTF_8));emit("action.resolved",transition,facts,data);
|
||||
}
|
||||
public void recoveryPending(Facts facts,UUID recovery,String reason){var phase=UUID.nameUUIDFromBytes((recovery+":recovery_completion_pending:"+facts.request().action()).getBytes(StandardCharsets.UTF_8));emit("action.resolved",phase,facts,Map.of("phase","recovery_completion_pending","outcome","pending_durable_completion","recovery_operation_id",recovery.toString(),"custody_status","recovery_prepared_completion_unacknowledged","sheet_status","exact_authorized_sheet_delivered","reason",reason,"causation","registered_recover_sheet"));}
|
||||
public void recovered(Facts facts,UUID recovery,DungeonDirectory.Publication publication,String delivery){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("outcome","acknowledged");data.put("custody_status","durably_completed");data.put("sheet_status",delivery);data.put("causation","registered_recover_sheet");data.put("recovery_operation_id",recovery.toString());var dungeon=facts.data().get("dungeon_id").toString();publication.after().dungeons().stream().filter(value->value.id().toString().equals(dungeon)).findFirst().ifPresent(value->data.put("after_revision",value.revision().toString()));emit("dungeon.character_boss.recovered",recovery,facts,data);
|
||||
}
|
||||
private void emit(String type,UUID transition,Facts facts,Map<String,Object> details){
|
||||
var data=new LinkedHashMap<>(facts.data());data.putAll(details);var request=facts.request();data.put("operation_id",request.action().toString());var actor=request.actor();if(actor!=null)data.putAll(actor.captured());else data.putIfAbsent("actor_kind","unavailable");if(actor==null||actor.context()==null){var original=data.get("actor_kind");if(!"console".equals(original)&&!"command_block".equals(original)){data.put("original_actor_kind",java.util.Objects.toString(original,"unavailable"));data.put("actor_kind","system");}data.putIfAbsent("location_status","unavailable");}var id=UUID.nameUUIDFromBytes(("fantasystick:character-boss:"+transition+":"+type).getBytes(StandardCharsets.UTF_8));emitBounded(new EventInput(id,request.action(),request.observedAt(),type,actor==null?null:actor.context(),data));
|
||||
}
|
||||
/** Only large original decimal stats are fragmented; envelope/codec/bound remain unchanged.
|
||||
* Part zero is the sole logical outcome; other rows carry exact linked original digits only.
|
||||
* Independent files are not a cross-crash atomic bundle: consumers must require all parts.
|
||||
*/
|
||||
private void emitBounded(EventInput event){
|
||||
var value=event.data().get("multiplied_stats");if(!(value instanceof Map<?,?> stats)||stats.values().stream().mapToLong(part->part.toString().length()).sum()<=4096){outbox.accepted(event);return;}
|
||||
var fragments=new java.util.ArrayList<Map<String,Object>>();stats.entrySet().stream().sorted(java.util.Comparator.comparing(entry->entry.getKey().toString())).forEach(entry->{var digits=entry.getValue().toString();for(int offset=0;offset<digits.length();offset+=1024)fragments.add(Map.of("stat",entry.getKey().toString(),"offset",offset,"total_digits",digits.length(),"digits",digits.substring(offset,Math.min(digits.length(),offset+1024))));});
|
||||
var base=new LinkedHashMap<>(event.data());base.remove("multiplied_stats");base.put("multiplied_stats_status","exact_linked_original_decimal_parts");base.put("multiplied_stats_count",stats.size());base.put("logical_event_id",event.id().toString());base.put("event_part_count",fragments.size()+1);base.put("parts_required_for_complete_stats",true);
|
||||
var continuation=new LinkedHashMap<String,Object>();for(var key:java.util.List.of("operation_id","logical_event_id","event_part_count","multiplied_stats_status","multiplied_stats_count","parts_required_for_complete_stats","subject_character_id","subject_character_revision","subject_sheet_generation","custody_operation_id","dungeon_id","room_id","generation_id","native_entity_id","multiplier","actor_kind","original_actor_kind"))if(base.containsKey(key))continuation.put(key,base.get(key));
|
||||
for(int index=0;index<=fragments.size();index++){var data=new LinkedHashMap<>(index==0?base:continuation);data.put("event_part_index",index);data.put("payload_part_kind",index==0?"original_facts_and_outcome":"multiplied_stat_digits");if(index>0)data.put("multiplied_stats_fragment",fragments.get(index-1));var id=index==0?event.id():UUID.nameUUIDFromBytes((event.id()+":part:"+index).getBytes(StandardCharsets.UTF_8));outbox.accepted(new EventInput(id,event.actionId(),event.time(),event.type(),event.context(),data));}
|
||||
}
|
||||
public void replay(){outbox.replay();}
|
||||
public CompletionStage<Void> drained(){return outbox.drained();}
|
||||
@Override public void close(){outbox.close();}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class CharacterBossAbilityEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
static final class NativeHealingGuard implements Listener {
|
||||
final boolean cancel;final List<EntityRegainHealthEvent> observed=new ArrayList<>();
|
||||
NativeHealingGuard(boolean cancel){this.cancel=cancel;}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void regain(EntityRegainHealthEvent event){if(event.getEntity().getType()!=org.bukkit.entity.EntityType.VINDICATOR)return;event.setAmount(5);event.setCancelled(cancel);observed.add(event);}
|
||||
}
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void actualSixReadyNativeSlotsDamageAndAdjustedOrCancelledHealingReachSystemCastOwnersWithoutTemplateMutation(boolean cancelled)throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();shell.entity.setHealth(50);b.nativeEffects.get(shell.entity.getUniqueId()).put(PotionEffectType.WEAKNESS,new PotionEffect(PotionEffectType.WEAKNESS,200,0));var nativeDamage=new ArrayList<EntityDamageByEntityEvent>();b.actor.damage=(amount,sourceEntity)->{var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(sourceEntity);when(source.getDirectEntity()).thenReturn(sourceEntity);var event=new EntityDamageByEntityEvent(sourceEntity,b.actor.player,EntityDamageEvent.DamageCause.MAGIC,source,amount);event.setCancelled(cancelled);b.f.dispatcher.accept(event);nativeDamage.add(event);};var healing=new NativeHealingGuard(cancelled);b.f.dispatcher.register(healing,b.f.plugin);fixture.damage(b.actor,shell,3,false);b.shared.pulse();assertEquals(2,nativeDamage.size(),"Both ready offensive slots actually traverse native damage dispatch in the same pulse");assertEquals(1,healing.observed.size());assertEquals(cancelled?50:55,shell.health[0],"Actual native adjusted/cancelled healing governs applied shell health, not requested ability amount");assertFalse(b.nativeEffects.get(shell.entity.getUniqueId()).containsKey(PotionEffectType.WEAKNESS));assertTrue(b.nativeEffects.get(shell.entity.getUniqueId()).containsKey(PotionEffectType.RESISTANCE));assertTrue(b.playerEffects.containsKey(PotionEffectType.WEAKNESS));assertTrue(b.playerEffects.containsKey(PotionEffectType.SLOWNESS));assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertEquals(fixture.template,((games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characters")).find(fixture.template.id()).get().orElseThrow());settle(b);
|
||||
var casts=owned("ability.cast");assertEquals(6,casts.size(),"Six actual ready slot attempts, including native rejected healing, need original independent system cast facts; ally-less Guardian Bond cannot be fabricated");assertEquals(Set.of("fireball","frost_nova","hex","second_wind","purify","stone_skin"),casts.stream().map(row->row.getAsJsonObject("data").get("ability_id").getAsString()).collect(java.util.stream.Collectors.toSet()));var resolved=owned("ability.resolved");assertEquals(6,resolved.size());var damage=owned("combat.damage");assertEquals(2,damage.size());for(int i=0;i<2;i++){var nativeEvent=nativeDamage.get(i);var abilityId=i==0?"fireball":"frost_nova";var matching=damage.stream().filter(row->row.getAsJsonObject("data").get("ability_id").getAsString().equals(abilityId)).findFirst().orElseThrow();var values=matching.getAsJsonObject("data");assertEquals(nativeEvent.isCancelled()?"cancelled":nativeEvent.getFinalDamage()>0?"accepted":"no_op",values.get("outcome").getAsString());assertEquals(nativeEvent.getFinalDamage(),values.get("final_damage").getAsDouble());assertEquals(b.actor.core.id.toString(),values.get("target_player_id").getAsString());assertEquals(b.actor.core.record.id().toString(),values.get("target_character_id").getAsString());}var healed=owned("combat.healed");assertEquals(1,healed.size());assertEquals(cancelled?"cancelled":"accepted",healed.getFirst().getAsJsonObject("data").get("outcome").getAsString());assertEquals(cancelled?0:5,healed.getFirst().getAsJsonObject("data").get("applied_amount").getAsDouble());assertEquals(shell.entity.getUniqueId().toString(),healed.getFirst().getAsJsonObject("data").get("target_entity_id").getAsString());for(var row:casts){var values=row.getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(shell.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals(fixture.custody.operation().toString(),values.get("custody_operation_id").getAsString());assertFalse(values.has("session_id"));assertFalse(row.has("playerid"));assertTrue(resolved.stream().anyMatch(result->result.getAsJsonObject("data").get("cast_id").equals(values.get("cast_id"))));}int attacks=nativeDamage.size();b.shared.pulse();assertEquals(attacks,nativeDamage.size(),"Existing native boss-only offensive cooldowns stay authoritative even when damage was cancelled");
|
||||
}
|
||||
}
|
||||
private List<com.google.gson.JsonObject> owned(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)&&row.getAsJsonObject("data").has("entity_role")&&row.getAsJsonObject("data").get("entity_role").getAsString().equals("character_boss")).toList();}
|
||||
private static void settle(DungeonAuditNativeFixture b)throws Exception{fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));}
|
||||
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Mob;
|
||||
|
||||
/** Feature-local extension of strict dungeon ports; real template custody, menus and registered commands remain installed. */
|
||||
final class CharacterBossAuditNativeFixture implements AutoCloseable {
|
||||
final DungeonAuditNativeFixture base;
|
||||
final FantasyStickLifecycleNativeTest.Actor templateActor;
|
||||
final CharacterRecord template;
|
||||
final Map<UUID,NativeBoss> shells=new HashMap<>();
|
||||
games.dmg.fantasystick.dungeon.DungeonState.CharacterBoss custody;
|
||||
static final class NativeBoss {
|
||||
final Mob entity;final double[] maximum={20},attack={1},health={20},absorption={0};final LivingEntity[] target={null};
|
||||
NativeBoss(Mob entity){this.entity=entity;}
|
||||
}
|
||||
CharacterBossAuditNativeFixture(Path data)throws Exception{this(data,List.of("speed_i","low_light_vision","quicker_regeneration_i"));}
|
||||
CharacterBossAuditNativeFixture(Path data,List<String> equippedPassives)throws Exception{this(data,equippedPassives,Map.of());}
|
||||
CharacterBossAuditNativeFixture(Path data,List<String> equippedPassives,Map<Stat,BigInteger> configuredStats)throws Exception{
|
||||
assertEquals(3,equippedPassives.size(),"Real template has exactly three passive slots");
|
||||
base=new DungeonAuditNativeFixture(data);templateActor=new FantasyStickLifecycleNativeTest.Actor(data);base.f.players.put(templateActor.core.id,templateActor.player);var old=templateActor.core.record;var stats=new java.util.EnumMap<Stat,BigInteger>(Stat.class);for(var stat:Stat.values())stats.put(stat,configuredStats.getOrDefault(stat,BigInteger.valueOf(30)));var abilities=List.of("fireball","frost_nova","hex","second_wind","purify","stone_skin","guardian_bond");
|
||||
template=new CharacterRecord(old.id(),"Original Template Boss",old.revision().add(BigInteger.ONE),stats,old.level(),old.experience(),old.unspentPoints(),BigDecimal.valueOf(40),Set.copyOf(abilities),abilities,List.copyOf(equippedPassives),Map.of("fireball",java.time.Instant.parse("2099-01-01T00:00:00Z")),old.restrictedKills(),false,null,null,old.coins(),old.coinsInitialized(),old.rewardReceipts(),old.pvpCoinEscrow(),new games.dmg.fantasystick.persistence.CharacterGear(games.dmg.fantasystick.persistence.GearTier.COPPER,games.dmg.fantasystick.persistence.GearTier.GOLD,games.dmg.fantasystick.persistence.GearTier.IRON),old.sheetGeneration());new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(template,old.revision());templateActor.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(template));var originalPort=base.shared.nativeSpawnSetup;base.shared.nativeSpawnSetup=entity->{originalPort.accept(entity);if(entity.getType()==EntityType.VINDICATOR)configure((Mob)entity);};
|
||||
}
|
||||
private void configure(Mob entity){
|
||||
var state=new NativeBoss(entity);shells.put(entity.getUniqueId(),state);var maximum=mock(org.bukkit.attribute.AttributeInstance.class);var attack=mock(org.bukkit.attribute.AttributeInstance.class);when(entity.getAttribute(Attribute.MAX_HEALTH)).thenReturn(maximum);when(entity.getAttribute(Attribute.ATTACK_DAMAGE)).thenReturn(attack);when(maximum.getValue()).thenAnswer(call->state.maximum[0]);when(attack.getValue()).thenAnswer(call->state.attack[0]);doAnswer(call->{state.maximum[0]=call.getArgument(0);return null;}).when(maximum).setBaseValue(anyDouble());doAnswer(call->{state.attack[0]=call.getArgument(0);return null;}).when(attack).setBaseValue(anyDouble());when(entity.getHealth()).thenAnswer(call->state.health[0]);doAnswer(call->{state.health[0]=call.getArgument(0);return null;}).when(entity).setHealth(anyDouble());when(entity.getEquipment()).thenReturn(mock(org.bukkit.inventory.EntityEquipment.class));when(entity.hasLineOfSight(any(Entity.class))).thenReturn(true);when(entity.getAbsorptionAmount()).thenAnswer(call->state.absorption[0]);doAnswer(call->{state.absorption[0]=call.getArgument(0);return null;}).when(entity).setAbsorptionAmount(anyDouble());doAnswer(call->{state.target[0]=call.getArgument(0);return null;}).when(entity).setTarget(any());when(entity.getTarget()).thenAnswer(call->state.target[0]);
|
||||
}
|
||||
void authorAndAccept()throws Exception{
|
||||
base.authorAndAccept("players",true,()->{var held=templateActor.core.inventory.getItemInOffHand();templateActor.core.inventory.setItemInOffHand(null);base.actor.core.inventory.setItemInMainHand(held);base.command("dungeon","room","boss","assign-sheet","2");custody=base.directory.custody(template.id()).orElseThrow();assertEquals(template,custody.character());assertTrue(base.actor.core.inventory.getItemInMainHand().isEmpty());});
|
||||
assertNotEquals(template.id(),base.actor.core.record.id());assertEquals(Set.of(base.actor.core.record.id()),base.quest.snapshot());assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(base.f.plugin,"sessions")).active(templateActor.core.id).isEmpty());
|
||||
}
|
||||
FantasyStickLifecycleNativeTest.Actor acceptedChallenger(Path data)throws Exception{
|
||||
var challenger=new FantasyStickLifecycleNativeTest.Actor(data);base.f.players.put(challenger.core.id,challenger.player);challenger.core.location.setWorld(base.actor.world);challenger.core.location.setX(12.75);challenger.core.location.setY(base.actor.core.location.getY());challenger.core.location.setZ(base.actor.core.location.getZ());doReturn(base.actor.world).when(challenger.player).getWorld();doReturn(false).when(challenger.player).isDead();DungeonAuditNativeFixture.effects(challenger.player,new HashMap<>());challenger.damage=(amount,source)->{};base.f.activate(challenger);base.shared.click(challenger,base.shared.open(challenger),false);ReskillEventsNativeTest.await(base.f,()->base.shared.directory.active().stream().anyMatch(quest->quest.snapshot().contains(challenger.core.record.id())));challenger.core.location.setX(17.75);return challenger;
|
||||
}
|
||||
void damage(FantasyStickLifecycleNativeTest.Actor actor,NativeBoss shell,double amount,boolean cancelled){
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var event=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,shell.entity,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,amount);event.setCancelled(cancelled);base.f.dispatcher.accept(event);assertEquals(cancelled,event.isCancelled());
|
||||
}
|
||||
NativeBoss shell(){var entity=base.nativeEntity(EntityType.VINDICATOR);return shells.get(entity.getUniqueId());}
|
||||
@Override public void close()throws java.io.IOException{base.close();try{WitchAuditTestSupport.drain(base.f,"characterBossDrain");}catch(Exception failure){throw new java.io.IOException("Character boss audit fixture did not drain",failure);}}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CharacterBossCustodyEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void authenticHeldInactivePartylessCharacterReachesRegisteredAssignmentConsumptionAndExactRecoveryBeforeIndependentOwnerSuccess()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();var originalLocation=actor.core.location.clone();
|
||||
command(f,actor,"dungeon","create","--mode","individual-only","Original Character Realm");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Custody Room");var room=source.find(dungeon.id()).orElseThrow().rooms().getFirst();command(f,actor,"dungeon","room","boss","mob","husk","Original Prior Guard");var prior=source.room(dungeon.id(),room.id()).orElseThrow().boss();var sheets=new CharacterSheets();var held=actor.core.inventory.getItemInOffHand();assertTrue(sheets.authorizes(held,saved));actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);var unrelated=java.util.Arrays.stream(actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);
|
||||
command(f,actor,"dungeon","room","boss","assign-sheet","1.125");var custody=source.custody(saved.id()).orElseThrow();assertEquals(saved,custody.character());assertEquals("1.125",custody.multiplier().toPlainString());assertEquals(prior.type(),custody.priorType());assertEquals(prior.name(),custody.priorName());assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());for(int slot=1;slot<unrelated.length;slot++)assertEquals(unrelated[slot],actor.core.inventory.getItem(slot));assertEquals(saved,storage.find(saved.id()).get().orElseThrow());assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).isEmpty());
|
||||
command(f,actor,"dungeon","edit","done");actor.core.location.setX(100);command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());assertTrue(source.custody(saved.id()).isEmpty());assertNull(source.room(dungeon.id(),room.id()).orElseThrow().boss());assertFalse(source.find(dungeon.id()).orElseThrow().enabled());var recovered=java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,saved)).toList();assertEquals(1,recovered.size());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());var recoveryLocation=actor.core.location.clone();ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.character_boss.assigned").size()==1&&rows("dungeon.character_boss.recovered").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
var assigned=rows("dungeon.character_boss.assigned");assertEquals(1,assigned.size(),"Actual saved custody plus exact physical consumption needs its independent success owner, not generic admin.action or definition configuration");var values=assigned.getFirst().getAsJsonObject("data");assertEquals(actor.core.id.toString(),assigned.getFirst().get("playerid").getAsString());assertFalse(values.has("session_id"));assertEquals(saved.id().toString(),values.get("subject_character_id").getAsString());assertEquals(saved.name(),values.get("subject_character_name").getAsString());assertEquals(saved.revision().toString(),values.get("subject_character_revision").getAsString());assertEquals(saved.sheetGeneration().toString(),values.get("subject_sheet_generation").getAsString());assertEquals(custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals(dungeon.id().toString(),values.get("dungeon_id").getAsString());assertEquals(room.id().toString(),values.get("room_id").getAsString());assertEquals("1.125",values.get("multiplier").getAsString());assertEquals(originalLocation.getX(),values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals("HUSK",values.get("prior_entity_type").getAsString());assertEquals("Original Prior Guard",values.get("prior_entity_name").getAsString());assertFalse(values.has("character_snapshot"));assertFalse(values.has("item"));
|
||||
var returns=rows("dungeon.character_boss.recovered");assertEquals(1,returns.size());var returned=returns.getFirst().getAsJsonObject("data");assertEquals(saved.id().toString(),returned.get("subject_character_id").getAsString());assertEquals(custody.operation().toString(),returned.get("custody_operation_id").getAsString());assertTrue(returned.has("recovery_operation_id"));assertEquals(recoveryLocation.getX(),returned.getAsJsonObject("actor_location").get("x").getAsDouble());assertTrue(rows("combat.reward_resolved").isEmpty());assertTrue(rows("dungeon.completed").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
|
||||
void acknowledgedOriginalCustodyWithoutDeferredConsumptionRemainsPendingNotSuccessAndExactOriginalSheetRecovers(boolean offline)throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();command(f,actor,"dungeon","create","Original Pending Custody");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Pending Room");var room=source.find(dungeon.id()).orElseThrow().rooms().getFirst();command(f,actor,"dungeon","room","boss","mob","husk","Original Pending Guard");var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);var originalInventory=java.util.Arrays.stream(actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);var originalLocation=actor.core.location.clone();
|
||||
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"dungeon","room","boss","assign-sheet","1.125"});if(offline)org.mockito.Mockito.doReturn(false).when(actor.player).isOnline();else org.mockito.Mockito.doReturn(false).when(actor.player).hasPermission("fantasystick.admin");actor.core.location.setX(100);ReskillEventsNativeTest.await(f,()->source.custody(saved.id()).isPresent());settleSourcesAndAudit(f,source,storage);var custody=source.custody(saved.id()).orElseThrow();assertEquals(saved,custody.character());assertArrayEquals(originalInventory,actor.core.inventory.getContents());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());assertTrue(rows("dungeon.character_boss.assigned").isEmpty(),"Saved source custody alone cannot prove physical sheet consumption");
|
||||
var pending=rows("action.resolved").stream().filter(row->row.getAsJsonObject("data").has("custody_operation_id")).toList();assertEquals(1,pending.size(),"An acknowledged source custody phase must remain observable without deferred reply permission/online status, explicitly pending rather than assigned");var values=pending.getFirst().getAsJsonObject("data");assertEquals("pending_native_consumption",values.get("outcome").getAsString());assertEquals("custody_saved",values.get("phase").getAsString());assertEquals(custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals(saved.id().toString(),values.get("subject_character_id").getAsString());assertEquals(actor.core.id.toString(),pending.getFirst().get("playerid").getAsString());assertEquals(originalLocation.getX(),values.getAsJsonObject("actor_location").get("x").getAsDouble());assertFalse(values.has("session_id"));
|
||||
org.mockito.Mockito.doReturn(true).when(actor.player).hasPermission("fantasystick.admin");org.mockito.Mockito.doReturn(true).when(actor.player).isOnline();command(f,actor,"dungeon","edit","done");command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.character_boss.recovered").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});assertTrue(source.custody(saved.id()).isEmpty());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());var sheets=new CharacterSheets();assertEquals(1,java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,saved)).count());f.plugin.setEnabled(false);f.awaitWorkers();assertEquals(1,rows("dungeon.character_boss.recovered").size());assertTrue(rows("dungeon.character_boss.assigned").isEmpty());assertTrue(rows("combat.reward_resolved").isEmpty());
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
|
||||
void deliveredOperationMarkedSheetWithFailedFinalCustodySaveRemainsPendingAndRetryCompletesExactRecoveryOnce(boolean restart)throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();command(f,actor,"dungeon","create","Original Interrupted Recovery");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Recovery Room");var room=source.find(dungeon.id()).orElseThrow().rooms().getFirst();command(f,actor,"dungeon","room","boss","mob","husk","Original Recovery Guard");var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);command(f,actor,"dungeon","room","boss","assign-sheet","1.125");var custody=source.custody(saved.id()).orElseThrow();assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());command(f,actor,"dungeon","edit","done");actor.core.location.setX(100);var originalLocation=actor.core.location.clone();var inventory=org.mockito.Mockito.spy(actor.core.inventory);org.mockito.Mockito.when(actor.player.getInventory()).thenReturn(inventory);var blocked=new java.util.concurrent.atomic.AtomicBoolean();var target=data.resolve("dungeons.yml");var backup=data.resolve("last-prepared-dungeons.yml");org.mockito.Mockito.doAnswer(call->{call.callRealMethod();if(blocked.compareAndSet(false,true)){Files.move(target,backup);Files.createDirectory(target);Files.writeString(target.resolve("blocked"),"test-only final source save refusal");}return null;}).when(inventory).setItem(org.mockito.ArgumentMatchers.eq(0),org.mockito.ArgumentMatchers.any(org.bukkit.inventory.ItemStack.class));
|
||||
command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());assertTrue(blocked.get());assertTrue(actor.messages.getLast().contains("durable custody completion must be retried"),actor.messages.toString());var prepared=source.custody(saved.id()).orElseThrow();assertNotNull(prepared.recovery());assertEquals(custody.operation(),prepared.operation());assertEquals(saved,prepared.character());var sheets=new CharacterSheets();var delivered=java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,saved)).toList();assertEquals(1,delivered.size());assertTrue(sheets.hasRecovery(delivered.getFirst(),prepared.recovery().operation()));assertEquals(saved,storage.find(saved.id()).get().orElseThrow());assertTrue(rows("dungeon.character_boss.recovered").isEmpty(),"Native sheet delivery cannot acknowledge failed durable custody completion");settleSourcesAndAudit(f,source,storage);
|
||||
var pending=rows("action.resolved").stream().filter(row->"recovery_completion_pending".equals(row.getAsJsonObject("data").has("phase")?row.getAsJsonObject("data").get("phase").getAsString():null)).toList();assertEquals(1,pending.size(),"Exact native recovery delivery with failed final source save must retain original pending evidence, not success or silence");var values=pending.getFirst().getAsJsonObject("data");assertEquals("pending_durable_completion",values.get("outcome").getAsString());assertEquals(prepared.recovery().operation().toString(),values.get("recovery_operation_id").getAsString());assertEquals(custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals(saved.id().toString(),values.get("subject_character_id").getAsString());assertEquals("exact_authorized_sheet_delivered",values.get("sheet_status").getAsString());assertEquals(originalLocation.getX(),values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(actor.core.id.toString(),pending.getFirst().get("playerid").getAsString());
|
||||
Files.delete(target.resolve("blocked"));Files.delete(target);Files.move(backup,target);if(restart){var persisted=Files.readAllBytes(target);var characterFile=data.resolve("characters").resolve(saved.id()+".yml");var characterBytes=Files.readAllBytes(characterFile);f.plugin.setEnabled(false);f.awaitWorkers();WitchAuditTestSupport.drain(f,"characterBossDrain");f.plugin.setEnabled(true);source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(prepared,source.custody(saved.id()).orElseThrow(),"Restart preserves exact pending custody, original template and recovery operation rather than deriving completion from physical delivery");assertEquals(saved,storage.find(saved.id()).get().orElseThrow());assertArrayEquals(persisted,Files.readAllBytes(target));assertArrayEquals(characterBytes,Files.readAllBytes(characterFile));assertTrue(sheets.hasRecovery(actor.core.inventory.getItem(0),prepared.recovery().operation()));assertTrue(rows("dungeon.character_boss.recovered").isEmpty());}actor.core.location.setX(105);command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.character_boss.recovered").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});assertTrue(source.custody(saved.id()).isEmpty());assertNull(source.room(dungeon.id(),room.id()).orElseThrow().boss());assertFalse(source.find(dungeon.id()).orElseThrow().enabled());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());var returned=java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,saved)).toList();assertEquals(1,returned.size());assertFalse(sheets.hasRecovery(returned.getFirst(),prepared.recovery().operation()));f.plugin.setEnabled(false);f.awaitWorkers();var returns=rows("dungeon.character_boss.recovered");assertEquals(1,returns.size());var returnedFacts=returns.getFirst().getAsJsonObject("data");assertEquals(prepared.recovery().operation().toString(),returnedFacts.get("recovery_operation_id").getAsString());assertEquals(105,returnedFacts.getAsJsonObject("actor_location").get("x").getAsDouble());assertTrue(rows("combat.reward_resolved").isEmpty());assertTrue(rows("dungeon.completed").isEmpty());
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
|
||||
void changedHeldSheetAfterSavedCustodyHasOriginalAcknowledgedRollbackOrPendingFailureEvidenceWithoutSuccess(boolean failedRollback)throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();command(f,actor,"dungeon","create","Original Rollback Custody");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Rollback Room");var room=source.find(dungeon.id()).orElseThrow().rooms().getFirst();command(f,actor,"dungeon","room","boss","mob","husk","Original Rollback Guard");var prior=source.room(dungeon.id(),room.id()).orElseThrow().boss();var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);var original=actor.core.location.clone();var target=data.resolve("dungeons.yml");var backup=data.resolve("last-assigned-dungeons.yml");var interrupted=new java.util.concurrent.atomic.AtomicBoolean();if(failedRollback){var inventory=org.mockito.Mockito.spy(actor.core.inventory);org.mockito.Mockito.when(actor.player.getInventory()).thenReturn(inventory);org.mockito.Mockito.doAnswer(call->{var item=call.callRealMethod();if(source.custody(saved.id()).isPresent()&&interrupted.compareAndSet(false,true)){Files.move(target,backup);Files.createDirectory(target);Files.writeString(target.resolve("blocked"),"test-only rollback source save refusal");}return item;}).when(inventory).getItemInMainHand();}
|
||||
var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(source,"worker");var entered=new java.util.concurrent.CountDownLatch(1);var release=new java.util.concurrent.CountDownLatch(1);worker.execute(()->{entered.countDown();try{if(!release.await(5,java.util.concurrent.TimeUnit.SECONDS))throw new AssertionError("Unreleased custody authority barrier");}catch(InterruptedException interruptedException){Thread.currentThread().interrupt();throw new AssertionError(interruptedException);}});assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));int messages=actor.messages.size();org.bukkit.inventory.ItemStack[] moved;try{f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"dungeon","room","boss","assign-sheet","1.125"});actor.core.inventory.setItemInMainHand(null);actor.core.inventory.setItem(5,held);actor.core.location.setX(100);moved=java.util.Arrays.stream(actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);}finally{release.countDown();}ReskillEventsNativeTest.await(f,()->actor.messages.size()>messages);assertArrayEquals(moved,actor.core.inventory.getContents());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());var expectedPhase=failedRollback?"assignment_rollback_pending":"assignment_cancelled";if(failedRollback){assertTrue(interrupted.get());assertTrue(actor.messages.getLast().contains("saved but sheet consumption could not be confirmed"),actor.messages.toString());assertEquals(saved,source.custody(saved.id()).orElseThrow().character());}else{assertTrue(actor.messages.getLast().contains("cancelled because the held sheet changed"),actor.messages.toString());assertTrue(source.custody(saved.id()).isEmpty());assertEquals(prior,source.room(dungeon.id(),room.id()).orElseThrow().boss());}settleSourcesAndAudit(f,source,storage);assertTrue(rows("dungeon.character_boss.assigned").isEmpty());var resolved=rows("action.resolved").stream().filter(row->expectedPhase.equals(row.getAsJsonObject("data").has("phase")?row.getAsJsonObject("data").get("phase").getAsString():null)).toList();assertEquals(1,resolved.size(),"Original acknowledged assignment rollback or unacknowledged rollback must reach its pending/cancelled owner, never assigned success or reconstructed current custody");var values=resolved.getFirst().getAsJsonObject("data");assertEquals(failedRollback?"pending_rollback":"cancelled",values.get("outcome").getAsString());assertEquals(saved.id().toString(),values.get("subject_character_id").getAsString());assertEquals(original.getX(),values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(actor.core.id.toString(),resolved.getFirst().get("playerid").getAsString());assertEquals("held_sheet_changed",values.get("reason").getAsString());assertTrue(values.has("custody_operation_id"));
|
||||
if(failedRollback){Files.delete(target.resolve("blocked"));Files.delete(target);Files.move(backup,target);}command(f,actor,"dungeon","edit","done");if(failedRollback){command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.character_boss.recovered").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});assertTrue(source.custody(saved.id()).isEmpty());}var sheets=new CharacterSheets();assertEquals(1,java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,saved)).count());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());f.plugin.setEnabled(false);f.awaitWorkers();assertTrue(rows("dungeon.character_boss.assigned").isEmpty());assertTrue(rows("combat.reward_resolved").isEmpty());assertTrue(rows("dungeon.completed").isEmpty());
|
||||
}
|
||||
}
|
||||
/** Test-local serialized fences; no source mutation, synthetic event or production lifecycle change. */
|
||||
private static void settleSourcesAndAudit(FantasyStickLifecycleNativeTest.Fixture f,DungeonDirectory source,CharacterStorage storage)throws Exception{
|
||||
fence(source,"worker");fence(storage,"worker");Runnable task;while((task=f.tasks.poll())!=null)task.run();
|
||||
var audit=FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterBossEvents");fence(FantasyStickLifecycleNativeTest.Fixture.field(audit,"outbox"),"worker");fence(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog"),"worker");
|
||||
}
|
||||
private static void fence(Object owner,String field)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,field)).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String...args)throws Exception{DungeonAuthoringEventsNativeTest.command(f,actor,args);}
|
||||
private List<com.google.gson.JsonObject> rows(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.events.CharacterBossEventBridge;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CharacterBossCustodyReplayNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void rejectedWriterCapturesExactRegisteredCustodyPhasesThenReplaysTwiceOfflineWithoutTemplateInventoryActorOrSourceRecreation()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");var folder=data.resolve("character-boss-event-intents");java.util.List<Properties> originals;byte[] definitions,character;Path characterFile;games.dmg.fantasystick.dungeon.DungeonState originalState;games.dmg.fantasystick.persistence.CharacterRecord originalCharacter;
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");originalCharacter=storage.find(actor.core.record.id()).get().orElseThrow();characterFile=data.resolve("characters").resolve(originalCharacter.id()+".yml");command(f,actor,"dungeon","create","Original Offline Custody");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Offline Room");command(f,actor,"dungeon","room","boss","mob","husk","Original Offline Guard");var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();command(f,actor,"dungeon","room","boss","assign-sheet","1.125");assertEquals(originalCharacter,source.custody(originalCharacter.id()).orElseThrow().character());assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());command(f,actor,"dungeon","edit","done");actor.core.location.setX(100);command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());assertTrue(source.custody(originalCharacter.id()).isEmpty());assertEquals(originalCharacter,storage.find(originalCharacter.id()).get().orElseThrow());f.plugin.setEnabled(false);f.awaitWorkers();WitchAuditTestSupport.drain(f,"characterBossDrain");originals=WitchAuditTestSupport.intents(folder);assertEquals(Map.of("action.resolved",1L,"dungeon.character_boss.assigned",1L,"dungeon.character_boss.recovered",1L),originals.stream().collect(java.util.stream.Collectors.groupingBy(value->value.getProperty("type"),java.util.stream.Collectors.counting())),"Rejected writer retains each original independent custody acknowledgement, not inferred success or gameplay recovery");originalState=new games.dmg.fantasystick.persistence.YamlDungeonRepository(data).load();definitions=Files.readAllBytes(data.resolve("dungeons.yml"));character=Files.readAllBytes(characterFile);
|
||||
}
|
||||
try(var reopened=new CampAuditNativeFixture(data)){
|
||||
var f=reopened.f;var actor=reopened.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).isEmpty());var inventory=java.util.Arrays.stream(actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);int messages=actor.messages.size();ReskillEventsNativeTest.await(f,()->{try{var rows=ReskillEventsNativeTest.records(data);return originals.stream().allMatch(intent->rows.stream().anyMatch(row->row.get("id").getAsString().equals(intent.getProperty("id"))));}catch(Exception failure){throw new AssertionError(failure);}});var audit=(CharacterBossEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterBossEvents");audit.replay();audit.replay();assertEquals(originalCharacter,storage.find(originalCharacter.id()).get().orElseThrow());f.plugin.setEnabled(false);f.awaitWorkers();WitchAuditTestSupport.drain(f,"characterBossDrain");var rows=ReskillEventsNativeTest.records(data);for(var intent:originals){var matching=rows.stream().filter(row->row.get("id").getAsString().equals(intent.getProperty("id"))).toList();assertEquals(1,matching.size());WitchAuditTestSupport.assertOriginal(intent,matching.getFirst());assertFalse(matching.getFirst().getAsJsonObject("data").has("session_id"));}assertEquals(originalState,source.snapshot());assertArrayEquals(definitions,Files.readAllBytes(data.resolve("dungeons.yml")));assertArrayEquals(character,Files.readAllBytes(characterFile));assertArrayEquals(inventory,actor.core.inventory.getContents());assertEquals(messages,actor.messages.size());assertTrue(reopened.spawned.isEmpty());assertTrue(source.custody(originalCharacter.id()).isEmpty());assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")||row.get("type").getAsString().endsWith("adventure.trophy_earned")));
|
||||
}
|
||||
}
|
||||
private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String...args)throws Exception{DungeonAuthoringEventsNativeTest.command(f,actor,args);}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class CharacterBossCustodySafetyNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@ParameterizedTest @ValueSource(strings={"permission","multiplier","duplicate","party","active","dead","stale","source_save","audit_storage"})
|
||||
void actualRegisteredRefusalOrUnavailableObservationCannotManufactureCustodySuccessOrChangeGameplay(String mode)throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new CampAuditNativeFixture(data)){
|
||||
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();command(f,actor,"dungeon","create","Original Safety Custody");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Safety Room");command(f,actor,"dungeon","room","boss","mob","husk","Original Safety Guard");var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);var target=data.resolve("dungeons.yml");var backup=data.resolve("before-refused-dungeons.yml");var definitions=Files.readAllBytes(target);
|
||||
switch(mode){
|
||||
case "permission"->org.mockito.Mockito.doReturn(false).when(actor.player).hasPermission("fantasystick.admin");
|
||||
case "duplicate"->actor.core.inventory.setItem(5,held.clone());
|
||||
case "party"->((games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties")).create(saved.id(),"Original Refused Party").get();
|
||||
case "active"->{actor.core.inventory.setItemInMainHand(null);actor.core.inventory.setItemInOffHand(held);f.activate(actor);assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).isPresent());actor.core.inventory.setItemInMainHand(held);}
|
||||
case "dead","stale"->{var changed=changed(saved,mode.equals("dead"));new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(changed,saved.revision());}
|
||||
case "source_save"->{Files.move(target,backup);Files.createDirectory(target);Files.writeString(target.resolve("blocked"),"test-only initial source save refusal");}
|
||||
case "audit_storage"->Files.writeString(data.resolve("character-boss-event-intents"),"test-only unavailable audit storage");
|
||||
default->{ }
|
||||
}
|
||||
var before=storage.find(saved.id()).get().orElseThrow();var snapshot=source.snapshot();var inventory=java.util.Arrays.stream(actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);command(f,actor,"dungeon","room","boss","assign-sheet",mode.equals("multiplier")?"bogus":"1.125");
|
||||
if(mode.equals("audit_storage")){
|
||||
assertEquals(before,source.custody(saved.id()).orElseThrow().character());assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());assertEquals(before,storage.find(saved.id()).get().orElseThrow());command(f,actor,"dungeon","edit","done");actor.core.location.setX(100);command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());assertTrue(source.custody(saved.id()).isEmpty());var sheets=new games.dmg.fantasystick.items.CharacterSheets();assertEquals(1,java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,before)).count());assertEquals("test-only unavailable audit storage",Files.readString(data.resolve("character-boss-event-intents")));
|
||||
}else{
|
||||
assertTrue(source.custody(saved.id()).isEmpty());assertEquals(snapshot,source.snapshot());assertArrayEquals(inventory,actor.core.inventory.getContents());assertEquals(before,storage.find(saved.id()).get().orElseThrow());if(mode.equals("source_save")){Files.delete(target.resolve("blocked"));Files.delete(target);Files.move(backup,target);}assertArrayEquals(definitions,Files.readAllBytes(target));
|
||||
}
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();WitchAuditTestSupport.drain(f,"characterBossDrain");var rows=ReskillEventsNativeTest.records(data);assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("dungeon.character_boss.assigned")||row.get("type").getAsString().endsWith("dungeon.character_boss.recovered")||row.getAsJsonObject("data").has("custody_operation_id")),"Refused/unobserved transitions cannot create an owner success or reconstruct unavailable metadata");assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")));
|
||||
}
|
||||
}
|
||||
private static CharacterRecord changed(CharacterRecord old,boolean dead){return new CharacterRecord(old.id(),old.name(),old.revision().add(java.math.BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),dead?java.math.BigDecimal.ZERO:old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),dead,dead?java.util.UUID.randomUUID():old.deathId(),dead?java.time.Instant.now():old.diedAt(),old.coins(),old.coinsInitialized(),old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),dead?old.sheetGeneration():old.sheetGeneration().add(java.math.BigInteger.ONE));}
|
||||
private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String...args)throws Exception{DungeonAuthoringEventsNativeTest.command(f,actor,args);}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class CharacterBossEffectEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void confirmedEquippedPassivesAndRealNativePotionAcceptanceCancellationExpirationAndShellCleanupKeepIndependentOriginalApplications(boolean cancelled)throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var shellEffects=b.nativeEffects.get(shell.entity.getUniqueId());assertTrue(shellEffects.containsKey(PotionEffectType.SPEED));assertTrue(shellEffects.containsKey(PotionEffectType.NIGHT_VISION));var unrelated=new PotionEffect(PotionEffectType.SATURATION,400,2);b.playerEffects.put(PotionEffectType.SATURATION,unrelated);var nativeEvents=new ArrayList<EntityPotionEffectEvent>();nativePotionPort(b,shell.entity,shellEffects,shell.entity,cancelled,nativeEvents,Set.of(PotionEffectType.RESISTANCE));nativePotionPort(b,b.actor.player,b.playerEffects,shell.entity,cancelled,nativeEvents,Set.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS));b.actor.damage=(amount,source)->{};fixture.damage(b.actor,shell,3,false);b.shared.pulse();assertEquals(3,nativeEvents.size());assertTrue(nativeEvents.stream().allMatch(event->event.isCancelled()==cancelled));assertEquals(!cancelled,shellEffects.containsKey(PotionEffectType.RESISTANCE));assertEquals(!cancelled,b.playerEffects.containsKey(PotionEffectType.WEAKNESS));assertEquals(!cancelled,b.playerEffects.containsKey(PotionEffectType.SLOWNESS));settle(b);var applied=owned("effect.applied");assertEquals(cancelled?2:5,applied.size(),"Only real confirmed equipped passive and accepted native AI layers need original application owners; cancelled calls are not application success");assertEquals(Set.of("speed_i","low_light_vision"),applied.stream().filter(row->row.getAsJsonObject("data").has("passive_id")).map(row->row.getAsJsonObject("data").get("passive_id").getAsString()).collect(java.util.stream.Collectors.toSet()));for(var row:applied){var values=row.getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertEquals(shell.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(fixture.custody.operation().toString(),values.get("custody_operation_id").getAsString());assertTrue(values.has("application_id"));assertTrue(values.has("source_location"));assertTrue(values.has("target_location"));assertFalse(values.has("session_id"));assertFalse(row.has("playerid"));}
|
||||
assertEquals(cancelled?Set.of():Set.of("hex","stone_skin","frost_nova"),applied.stream().filter(row->row.getAsJsonObject("data").has("ability_id")).map(row->row.getAsJsonObject("data").get("ability_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
for(var id:List.of("hex","stone_skin","frost_nova")){var result=owned("ability.resolved").stream().filter(row->row.getAsJsonObject("data").get("ability_id").getAsString().equals(id)).findFirst().orElseThrow();assertEquals(cancelled?"cancelled":"accepted",result.getAsJsonObject("data").get("native_effect_outcome").getAsString());}
|
||||
if(cancelled){for(var id:List.of("hex","stone_skin","frost_nova")){var result=owned("ability.resolved").stream().filter(row->row.getAsJsonObject("data").get("ability_id").getAsString().equals(id)).findFirst().orElseThrow();assertEquals("cancelled",result.getAsJsonObject("data").get("native_effect_outcome").getAsString());assertTrue(result.getAsJsonObject("data").get("cooldown_committed").getAsBoolean(),"Existing native call-based cooldown decisions are not changed by observation");}}
|
||||
else{for(var type:List.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS)){var old=b.playerEffects.get(type);var event=new EntityPotionEffectEvent(b.actor.player,old,null,shell.entity,EntityPotionEffectEvent.Cause.EXPIRATION,EntityPotionEffectEvent.Action.REMOVED,false);b.f.dispatcher.accept(event);assertFalse(event.isCancelled());b.playerEffects.remove(type);}settle(b);assertEquals(2,owned("effect.ended").size(),"Accepted real expiration releases each original AI target layer once");}
|
||||
b.actor.core.location.setX(100);b.shared.pulse();assertFalse(shell.entity.isValid());settle(b);assertEquals(unrelated,b.playerEffects.get(PotionEffectType.SATURATION));var ended=owned("effect.ended");assertEquals(applied.size(),ended.size(),"Actual expiration plus native shell lifetime end account for original owned layers without pulse duplicates");for(var row:applied)assertEquals(1,ended.stream().filter(end->end.getAsJsonObject("data").get("application_id").equals(row.getAsJsonObject("data").get("application_id"))).count());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")));
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.api.Test
|
||||
void actualUnrelatedStrongerNativeReplacementEndsOnlyTheOriginalPassiveAndPreservesTheNewLayer()throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var layers=b.nativeEffects.get(shell.entity.getUniqueId());settle(b);
|
||||
var original=layers.get(PotionEffectType.SPEED);assertNotNull(original);assertEquals(2,owned("effect.applied").size());
|
||||
var stronger=new PotionEffect(PotionEffectType.SPEED,200,2,false,false);
|
||||
var replacement=new EntityPotionEffectEvent(shell.entity,original,stronger,shell.entity,EntityPotionEffectEvent.Cause.PLUGIN,EntityPotionEffectEvent.Action.CHANGED,true);
|
||||
b.f.dispatcher.accept(replacement);assertFalse(replacement.isCancelled());layers.put(PotionEffectType.SPEED,stronger);settle(b);
|
||||
var applied=owned("effect.applied");var ended=owned("effect.ended");assertEquals(2,applied.size());assertEquals(1,ended.size());assertEquals(applied.stream().filter(row->"speed_i".equals(row.getAsJsonObject("data").get("passive_id").getAsString())).findFirst().orElseThrow().getAsJsonObject("data").get("application_id"),ended.getFirst().getAsJsonObject("data").get("application_id"));
|
||||
assertEquals(stronger,layers.get(PotionEffectType.SPEED));b.actor.core.location.setX(100);b.shared.pulse();settle(b);assertEquals(stronger,layers.get(PotionEffectType.SPEED));assertEquals(2,owned("effect.ended").size());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.api.Test
|
||||
void originalNativeExpirationCapturesItsOwnTimeAndTargetLocationBeforeDeferredConfirmationNotApplicationOrLaterMovement()throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var observed=new ArrayList<EntityPotionEffectEvent>();nativePotionPort(b,b.actor.player,b.playerEffects,shell.entity,false,observed,Set.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS));b.actor.damage=(amount,source)->{};fixture.damage(b.actor,shell,3,false);b.shared.pulse();settle(b);var application=owned("effect.applied").stream().filter(row->row.getAsJsonObject("data").has("ability_id")&&row.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).findFirst().orElseThrow();assertEquals(17.5,application.getAsJsonObject("data").getAsJsonObject("target_location").get("x").getAsDouble());b.actor.core.location.setX(17.8);var original=b.playerEffects.get(PotionEffectType.WEAKNESS);var before=java.time.Instant.now();var event=new EntityPotionEffectEvent(b.actor.player,original,null,shell.entity,EntityPotionEffectEvent.Cause.EXPIRATION,EntityPotionEffectEvent.Action.REMOVED,false);b.f.dispatcher.accept(event);assertFalse(event.isCancelled());b.playerEffects.remove(PotionEffectType.WEAKNESS);var after=java.time.Instant.now();b.actor.core.location.setX(100);settle(b);var ended=owned("effect.ended");assertEquals(1,ended.size());var values=ended.getFirst().getAsJsonObject("data");assertEquals(application.getAsJsonObject("data").get("application_id"),values.get("application_id"));assertEquals(17.8,values.getAsJsonObject("target_location").get("x").getAsDouble(),"Original native end location is neither application location nor deferred/current player location");var time=java.time.Instant.parse(ended.getFirst().get("time").getAsString());assertFalse(time.isBefore(before),"End timestamp belongs to original native expiration, not original application");assertFalse(time.isAfter(after),"Deferred native confirmation cannot replace original end time");assertEquals(17.5,values.getAsJsonObject("application_target_location").get("x").getAsDouble());assertEquals("system",values.get("actor_kind").getAsString());assertFalse(values.has("session_id"));assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.api.Test
|
||||
void priorCancelledNativeRemovalCannotEndOrReplaceTheOriginalPassiveApplication()throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var layers=b.nativeEffects.get(shell.entity.getUniqueId());var original=layers.get(PotionEffectType.SPEED);settle(b);var event=new EntityPotionEffectEvent(shell.entity,original,null,shell.entity,EntityPotionEffectEvent.Cause.PLUGIN,EntityPotionEffectEvent.Action.REMOVED,false);event.setCancelled(true);b.f.dispatcher.accept(event);settle(b);assertTrue(event.isCancelled());assertEquals(original,layers.get(PotionEffectType.SPEED));assertEquals(2,owned("effect.applied").size());assertTrue(owned("effect.ended").isEmpty());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void optionalPostRemovalValidityFailureKeepsOriginalPendingLayerFactsAndOnlyActualLaterConfirmationEndsThem(boolean staysUnavailable)throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();settle(b);var applications=owned("effect.applied");assertEquals(2,applications.size());var removed=new java.util.concurrent.atomic.AtomicBoolean();doAnswer(call->{removed.set(true);return null;}).when(shell.entity).remove();doAnswer(call->{if(removed.get())throw new IllegalStateException("test-only optional post-removal validity unavailable");return true;}).when(shell.entity).isValid();b.actor.core.location.setX(100);var before=java.time.Instant.now();assertDoesNotThrow(b.shared::pulse,"An added optional native-effect end confirmation must not interrupt original native room cleanup after remove returned");var after=java.time.Instant.now();assertTrue(removed.get());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertFalse(b.directory.runView(b.definition.id(),b.quest.catalog()).orElseThrow().defeated());if(!staysUnavailable)doReturn(false).when(shell.entity).isValid();settle(b);
|
||||
var pending=owned("action.resolved").stream().filter(row->row.getAsJsonObject("data").has("phase")&&row.getAsJsonObject("data").get("phase").getAsString().equals("native_effect_end_confirmation_pending")).toList();assertEquals(2,pending.size(),"Actual unavailable lifetime confirmation needs qualified original application evidence, never orphaned or successful end");for(var row:pending){var facts=row.getAsJsonObject("data");assertEquals("unconfirmed",facts.get("outcome").getAsString());assertEquals("remove_returned_validity_unavailable",facts.get("end_status").getAsString());assertTrue(applications.stream().anyMatch(application->application.getAsJsonObject("data").get("application_id").equals(facts.get("application_id"))));var time=java.time.Instant.parse(row.get("time").getAsString());assertFalse(time.isBefore(before));assertFalse(time.isAfter(after));assertEquals(17.25,facts.getAsJsonObject("target_location").get("x").getAsDouble());}
|
||||
var ended=owned("effect.ended");assertEquals(staysUnavailable?0:2,ended.size(),"Only actual later native invalidity can confirm the original pending shell lifetime");for(var row:ended){var time=java.time.Instant.parse(row.get("time").getAsString());assertFalse(time.isBefore(before));assertFalse(time.isAfter(after));assertEquals("native_invalid_confirmed_after_original_remove",row.getAsJsonObject("data").get("reason").getAsString());}settle(b);assertEquals(staysUnavailable?0:2,owned("effect.ended").size());assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")));doReturn(false).when(shell.entity).isValid();
|
||||
}
|
||||
}
|
||||
@ParameterizedTest @ValueSource(strings={"unrelated_nested","unchanged_existing"})
|
||||
void actualNullSourcePotionCallsCannotAttributeAnUnrelatedDamageCallbackOrAnUnchangedPriorLayer(String mode)throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var observed=new ArrayList<EntityPotionEffectEvent>();nativePotionPort(b,shell.entity,b.nativeEffects.get(shell.entity.getUniqueId()),shell.entity,false,observed,Set.of(PotionEffectType.RESISTANCE));nativePotionPort(b,b.actor.player,b.playerEffects,shell.entity,false,observed,Set.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS,PotionEffectType.LUCK));if(mode.equals("unchanged_existing"))b.playerEffects.put(PotionEffectType.SLOWNESS,new PotionEffect(PotionEffectType.SLOWNESS,40,0,false,true));b.actor.damage=(amount,source)->{if(mode.equals("unrelated_nested"))b.actor.player.addPotionEffect(new PotionEffect(PotionEffectType.LUCK,100,1,false,false),true);};fixture.damage(b.actor,shell,3,false);b.shared.pulse();settle(b);assertTrue(observed.stream().allMatch(event->event.getSource()==null),"Pinned Bukkit addPotionEffect dispatches null native sources");assertTrue(b.playerEffects.containsKey(PotionEffectType.WEAKNESS));assertTrue(b.playerEffects.containsKey(PotionEffectType.SLOWNESS));assertTrue(b.nativeEffects.get(shell.entity.getUniqueId()).containsKey(PotionEffectType.RESISTANCE));assertEquals(mode.equals("unchanged_existing")?4:5,owned("effect.applied").size(),"Only actual changed intended native layers may be attributed to their exact cast");assertTrue(owned("effect.applied").stream().noneMatch(row->{var values=row.getAsJsonObject("data");return values.has("ability_id")&&values.get("ability_id").getAsString().equals("fireball");}),"An unrelated reentrant damage callback cannot become Fireball's potion application");if(mode.equals("unchanged_existing"))assertTrue(owned("effect.applied").stream().noneMatch(row->{var values=row.getAsJsonObject("data");return values.has("ability_id")&&values.get("ability_id").getAsString().equals("frost_nova");}));assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
static void nativePotionPort(DungeonAuditNativeFixture b,LivingEntity target,Map<PotionEffectType,PotionEffect> layers,Entity source,boolean cancelled,List<EntityPotionEffectEvent> observed,Set<PotionEffectType> types){
|
||||
doAnswer(call->{PotionEffect effect=call.getArgument(0);if(!types.contains(effect.getType())){layers.put(effect.getType(),effect);return true;}var old=layers.get(effect.getType());var event=new EntityPotionEffectEvent(target,old,effect,null,EntityPotionEffectEvent.Cause.PLUGIN,old==null?EntityPotionEffectEvent.Action.ADDED:EntityPotionEffectEvent.Action.CHANGED,true);event.setCancelled(cancelled);b.f.dispatcher.accept(event);observed.add(event);if(!event.isCancelled())layers.put(effect.getType(),effect);return !event.isCancelled();}).when(target).addPotionEffect(any(PotionEffect.class),anyBoolean());
|
||||
}
|
||||
private List<com.google.gson.JsonObject> owned(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)&&row.getAsJsonObject("data").has("entity_role")&&row.getAsJsonObject("data").get("entity_role").getAsString().equals("character_boss")).toList();}
|
||||
private static void settle(DungeonAuditNativeFixture b)throws Exception{Runnable reply;while((reply=b.f.tasks.poll())!=null)reply.run();fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));}
|
||||
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityKnockbackEvent;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class CharacterBossMeleePassiveNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@ParameterizedTest @org.junit.jupiter.params.provider.CsvSource({"accepted,ENTITY_ATTACK","cancelled_knockback,ENTITY_ATTACK","unchanged_velocity,ENTITY_ATTACK","native_event_unavailable,ENTITY_ATTACK","accepted,MAGIC","cancelled_knockback,MAGIC","unchanged_velocity,MAGIC","native_event_unavailable,MAGIC","late_cancelled_damage,ENTITY_ATTACK","late_cancelled_damage,MAGIC"})
|
||||
void actualEligibleNativeDamageKnockbackPreservesOriginalCausePolicyAndConfirmsOnlyFinalNativeDecisionAndVelocity(String mode,String cause) throws Exception {
|
||||
try (var fixture=new CharacterBossAuditNativeFixture(data,List.of("stick_knockback","speed_i","low_light_vision"))) {
|
||||
var b=fixture.base; fixture.authorAndAccept(); b.enter(b.boss); var shell=fixture.shell();
|
||||
var velocity=new Vector[]{new Vector()}; var nativeCalls=new ArrayList<EntityKnockbackEvent>();
|
||||
doAnswer(call->{if(mode.equals("native_event_unavailable"))return null;var event=new EntityKnockbackEvent(b.actor.player,EntityKnockbackEvent.KnockbackCause.ENTITY_ATTACK,1,velocity[0].clone(),new Vector(0,0,1)); b.f.dispatcher.accept(event);nativeCalls.add(event);if(!event.isCancelled()&&!mode.equals("unchanged_velocity"))velocity[0]=event.getFinalKnockback().clone();return null;}).when(b.actor.player).knockback(anyDouble(),anyDouble(),anyDouble());
|
||||
doAnswer(call->velocity[0].clone()).when(b.actor.player).getVelocity();
|
||||
b.f.dispatcher.register(new Listener(){@EventHandler(priority=EventPriority.HIGHEST)public void veto(EntityKnockbackEvent event){if(mode.equals("cancelled_knockback"))event.setCancelled(true);}@EventHandler(priority=EventPriority.MONITOR)public void finalDamageDecision(EntityDamageByEntityEvent event){if(mode.equals("late_cancelled_damage")&&event.getDamager()==shell.entity)event.setCancelled(true);}},b.f.plugin);
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(shell.entity);when(source.getDirectEntity()).thenReturn(shell.entity);
|
||||
var refused=new EntityDamageByEntityEvent(shell.entity,b.actor.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);refused.setCancelled(true);b.f.dispatcher.accept(refused);
|
||||
b.actor.core.location.setX(100);var ineligible=new EntityDamageByEntityEvent(shell.entity,b.actor.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);b.f.dispatcher.accept(ineligible);assertTrue(ineligible.isCancelled());b.actor.core.location.setX(17.5);
|
||||
assertTrue(nativeCalls.isEmpty(),"Prior cancellation and room refusal never invoke native passive knockback");
|
||||
var accepted=new EntityDamageByEntityEvent(shell.entity,b.actor.player,EntityDamageEvent.DamageCause.valueOf(cause),source,3);b.f.dispatcher.accept(accepted);assertEquals(mode.equals("late_cancelled_damage"),accepted.isCancelled());assertEquals(mode.equals("native_event_unavailable")?0:1,nativeCalls.size());verify(b.actor.player,times(1)).knockback(eq(1d),anyDouble(),anyDouble());
|
||||
assertEquals((mode.equals("accepted")||mode.equals("late_cancelled_damage"))?1:0,velocity[0].getZ());assertEquals(fixture.template,b.directory.custody(fixture.template.id()).orElseThrow().character());settle(b);
|
||||
var damage=owned("combat.damage");assertEquals(1,damage.size(),"Actual accepted native melee needs a distinct system-shell damage observation, not an ability/basic-stick copy");
|
||||
var facts=damage.getFirst().getAsJsonObject("data");assertEquals(cause.equals("ENTITY_ATTACK")?"native_melee":"native_entity_damage",facts.get("damage_source").getAsString());assertEquals(cause,facts.get("damage_cause").getAsString());assertEquals(3,facts.get("final_damage").getAsDouble());assertEquals(mode.equals("late_cancelled_damage")?"cancelled":"accepted",facts.get("outcome").getAsString());assertEquals("system",facts.get("actor_kind").getAsString());assertEquals(b.actor.core.record.id().toString(),facts.get("target_character_id").getAsString());assertEquals(fixture.template.id().toString(),facts.get("subject_character_id").getAsString());assertFalse(damage.getFirst().has("playerid"));assertFalse(facts.has("session_id"));
|
||||
var applied=owned("effect.applied").stream().filter(row->row.getAsJsonObject("data").has("passive_id")&&"stick_knockback".equals(row.getAsJsonObject("data").get("passive_id").getAsString())).toList();
|
||||
assertEquals((mode.equals("accepted")||mode.equals("late_cancelled_damage"))?1:0,applied.size(),"A returned native knockback call is not confirmed application after cancellation/no velocity change");
|
||||
if(!applied.isEmpty()){var effect=applied.getFirst().getAsJsonObject("data");assertEquals("accepted",effect.get("outcome").getAsString());assertEquals(facts.get("operation_id").getAsString(),effect.get("melee_damage_operation_id").getAsString());assertEquals(1,effect.getAsJsonObject("velocity_after").get("z").getAsDouble());assertEquals(shell.entity.getUniqueId().toString(),effect.get("native_entity_id").getAsString());assertEquals(b.actor.core.id.toString(),effect.get("target_player_id").getAsString());assertFalse(applied.getFirst().has("playerid"));}
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void registeredDowningPreservesOriginalHealthStealEligibilityRefusalWithoutInventingHeal(boolean cancelled) throws Exception {
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data,List.of("health_steal","speed_i","low_light_vision"))){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();shell.entity.setHealth(50);
|
||||
var calls=new ArrayList<EntityRegainHealthEvent>();b.f.dispatcher.register(new Listener(){@EventHandler(priority=EventPriority.HIGHEST)public void adjust(EntityRegainHealthEvent event){if(event.getEntity()!=shell.entity)return;calls.add(event);event.setAmount(4);event.setCancelled(cancelled);}},b.f.plugin);
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(shell.entity);when(source.getDirectEntity()).thenReturn(shell.entity);
|
||||
var hit=new EntityDamageByEntityEvent(shell.entity,b.actor.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,5);b.f.dispatcher.accept(hit);assertFalse(hit.isCancelled());doReturn(hit).when(b.actor.player).getLastDamageCause();
|
||||
var deathSource=new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(DamageType.GENERIC)));
|
||||
var death=new PlayerDeathEvent(b.actor.player,deathSource,new ArrayList<>(),0,net.kyori.adventure.text.Component.empty(),true);b.f.dispatcher.accept(death);
|
||||
assertTrue(death.isCancelled(),"Existing registered fantasy downing contains the original native death; never suppress this guard for an audit test");
|
||||
assertTrue(calls.isEmpty(),"Registered downing removes original eligibility before Health Steal; audit cannot bypass it or invent a callback");assertEquals(50,shell.health[0]);assertEquals(fixture.template,b.directory.custody(fixture.template.id()).orElseThrow().character());settle(b);
|
||||
assertTrue(owned("combat.healed").isEmpty(),"No native regain or health change may become Health Steal success");assertTrue(owned("ability.cast").isEmpty());
|
||||
}
|
||||
}
|
||||
private List<com.google.gson.JsonObject> owned(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)&&row.getAsJsonObject("data").has("entity_role")&&row.getAsJsonObject("data").get("entity_role").getAsString().equals("character_boss")).toList();}
|
||||
private static void settle(DungeonAuditNativeFixture b)throws Exception{Runnable task;while((task=b.f.tasks.poll())!=null)task.run();fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));}
|
||||
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.events.CharacterBossEventBridge;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CharacterBossMetadataSafetyNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void optionalActorCaptureFailureCannotEraseAuthenticatedAdministratorOrReclassifyRegisteredCustodyAsSystem() throws Exception {
|
||||
try(var fixture=new CampAuditNativeFixture(data)) {
|
||||
var f=fixture.f;var actor=fixture.actor;
|
||||
var source=(games.dmg.fantasystick.dungeon.DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");
|
||||
var owner=(CharacterBossEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterBossEvents");
|
||||
var capture=CharacterBossEventBridge.class.getDeclaredField("capture");capture.setAccessible(true);
|
||||
capture.set(owner,(java.util.function.Function<org.bukkit.command.CommandSender,games.dmg.fantasystick.events.CampEventBridge.Actor>)sender->{throw new IllegalStateException("test-only optional capture unavailable");});
|
||||
command(f,actor,"dungeon","create","Original Capture Failure");var dungeon=source.snapshot().dungeons().getFirst();
|
||||
command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Room");command(f,actor,"dungeon","room","boss","mob","husk","Original Guard");
|
||||
var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);
|
||||
command(f,actor,"dungeon","room","boss","assign-sheet","1.125");assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());assertEquals(actor.core.record.id(),source.custody(actor.core.record.id()).orElseThrow().character().id());
|
||||
command(f,actor,"dungeon","edit","done");actor.core.location.setX(100);command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());assertTrue(source.custody(actor.core.record.id()).isEmpty());
|
||||
fence(FantasyStickLifecycleNativeTest.Fixture.field(owner,"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog"));
|
||||
for(var type:List.of("dungeon.character_boss.assigned","dungeon.character_boss.recovered")) {
|
||||
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();assertEquals(1,rows.size());
|
||||
var row=rows.getFirst();assertEquals("player",row.getAsJsonObject("data").get("actor_kind").getAsString(),"Optional metadata failure cannot turn the authenticated administrator into a system actor");assertTrue(row.has("playerid"));assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertFalse(row.getAsJsonObject("data").has("session_id"));assertFalse(row.getAsJsonObject("data").has("actor_location"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void oneUnavailablePassiveLayerReadCannotHideAnotherConfirmedEquippedNativeLayer() throws Exception {
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)) {
|
||||
var b=fixture.base;fixture.authorAndAccept();var reads=new java.util.concurrent.atomic.AtomicInteger();
|
||||
b.shared.nativeSpawnOutcome=entity->{if(entity.getType()==EntityType.VINDICATOR)doAnswer(call->{if(reads.incrementAndGet()==1)throw new IllegalStateException("test-only first passive observation unavailable");return new ArrayList<>(b.nativeEffects.get(entity.getUniqueId()).values());}).when(entity).getActivePotionEffects();};
|
||||
assertDoesNotThrow(()->b.enter(b.boss));var shell=fixture.shell();assertTrue(shell.entity.isValid());assertTrue(b.nativeEffects.get(shell.entity.getUniqueId()).containsKey(PotionEffectType.SPEED));assertTrue(b.nativeEffects.get(shell.entity.getUniqueId()).containsKey(PotionEffectType.NIGHT_VISION));
|
||||
fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));
|
||||
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("effect.applied")&&row.getAsJsonObject("data").has("passive_id")).toList();assertEquals(1,rows.size(),"A failed optional Speed confirmation cannot hide independently confirmed Night Vision");assertEquals("low_light_vision",rows.getFirst().getAsJsonObject("data").get("passive_id").getAsString());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void authenticVeryLargePersistedStatReachesBoundedOriginalCustodyAndNativeCastProofThenExactOfflineReplay() throws Exception {
|
||||
var huge=BigInteger.ONE.shiftLeft(60_000);List<java.util.Properties> originals;byte[] definitions,character;Path subjectFile;String expected;
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data,List.of("speed_i","low_light_vision","quicker_regeneration_i"),Map.of(Stat.INT,huge))) {
|
||||
var b=fixture.base;((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog")).close();fixture.authorAndAccept();subjectFile=data.resolve("characters").resolve(fixture.template.id()+".yml");character=Files.readAllBytes(subjectFile);b.enter(b.boss);var shell=fixture.shell();assertTrue(shell.entity.isValid());assertEquals(70,shell.health[0]);b.actor.damage=(amount,source)->{};fixture.damage(b.actor,shell,3,false);b.shared.pulse();assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());expected=huge.multiply(BigInteger.TWO).toString();
|
||||
b.f.plugin.setEnabled(false);b.f.awaitWorkers();WitchAuditTestSupport.drain(b.f,"characterBossDrain");originals=WitchAuditTestSupport.intents(data.resolve("character-boss-event-intents"));
|
||||
for(var type:List.of("dungeon.character_boss.assigned","dungeon.entity.spawned","ability.cast","ability.resolved"))assertTrue(originals.stream().anyMatch(p->p.getProperty("type").equals(type)),"Supported exact large stat must not drop every independent owner: "+type);
|
||||
try(var paths=Files.list(data.resolve("character-boss-event-intents"))) {for(var path:paths.filter(p->p.toString().endsWith(".pending")).toList())assertTrue(Files.size(path)<=16_384,"Shared durable intent bound stays unchanged");}
|
||||
definitions=Files.readAllBytes(data.resolve("dungeons.yml"));assertArrayEquals(character,Files.readAllBytes(subjectFile));
|
||||
}
|
||||
var failures=new ArrayList<Throwable>();
|
||||
try(var writer=LocalEventLog.open(data,java.time.Clock.systemUTC(),new LocalEventLog.RuntimeInfo("offline metadata","pinned-native","26.2","test"),Math.max(128,originals.size()+32),1_048_576,message->{throw new AssertionError(message);})) {
|
||||
writer.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);var audit=new CharacterBossEventBridge(input->writer.emit(input).thenApply(delivery->{assertNotEquals(LocalEventLog.Delivery.REJECTED,delivery,"Offline exact-replay control must accept original "+input.id());return delivery;}),data,sender->{throw new AssertionError("No current actor reconstruction");},failures::add);try(audit){audit.replay();audit.replay();}audit.drained().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(writer,"worker")).submit(()->{}).get(120,java.util.concurrent.TimeUnit.SECONDS);
|
||||
}
|
||||
assertTrue(failures.isEmpty(),failures.toString());var rows=ReskillEventsNativeTest.records(data);
|
||||
for(var intent:originals){var matching=rows.stream().filter(row->row.get("id").getAsString().equals(intent.getProperty("id"))).toList();assertEquals(1,matching.size());WitchAuditTestSupport.assertOriginal(intent,matching.getFirst());}
|
||||
for(var type:List.of("dungeon.character_boss.assigned","dungeon.entity.spawned","ability.cast","ability.resolved")) {
|
||||
var matching=rows.stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();assertFalse(matching.isEmpty());
|
||||
var logical=matching.getFirst().getAsJsonObject("data").get("operation_id");var group=matching.stream().filter(row->row.getAsJsonObject("data").get("operation_id").equals(logical)).toList();
|
||||
var main=group.stream().filter(row->row.getAsJsonObject("data").get("event_part_index").getAsInt()==0).toList();assertEquals(1,main.size(),"Only part zero is the logical outcome");var head=main.getFirst();var headData=head.getAsJsonObject("data");assertEquals(group.size(),headData.get("event_part_count").getAsInt());assertEquals(6,headData.get("multiplied_stats_count").getAsInt());assertEquals(head.get("id").getAsString(),headData.get("logical_event_id").getAsString());assertEquals(java.util.stream.IntStream.range(0,group.size()).boxed().toList(),group.stream().map(row->row.getAsJsonObject("data").get("event_part_index").getAsInt()).sorted().toList());for(var row:group){assertEquals(head.get("time"),row.get("time"));assertEquals(headData.get("logical_event_id"),row.getAsJsonObject("data").get("logical_event_id"));assertEquals(headData.get("subject_character_id"),row.getAsJsonObject("data").get("subject_character_id"));if(row.getAsJsonObject("data").get("event_part_index").getAsInt()>0){for(var outcomeField:List.of("outcome","custody_status","sheet_status","cooldown_committed","application_id"))assertFalse(row.getAsJsonObject("data").has(outcomeField),"Decimal-only continuation is not another gameplay acknowledgement: "+outcomeField);}}
|
||||
for(var stat:List.of("STR","DEX","CON","WIS","CHR")){var values=group.stream().map(row->row.getAsJsonObject("data")).filter(value->value.has("multiplied_stats_fragment")&&stat.equals(value.getAsJsonObject("multiplied_stats_fragment").get("stat").getAsString())).toList();assertEquals(1,values.size());assertEquals("60",values.getFirst().getAsJsonObject("multiplied_stats_fragment").get("digits").getAsString());}
|
||||
var exact=new StringBuilder();
|
||||
var fragments=group.stream().map(row->row.getAsJsonObject("data")).filter(value->value.has("multiplied_stats_fragment")&&"INT".equals(value.getAsJsonObject("multiplied_stats_fragment").get("stat").getAsString())).sorted(java.util.Comparator.comparingInt(value->value.getAsJsonObject("multiplied_stats_fragment").get("offset").getAsInt())).toList();
|
||||
for(var value:fragments){var fragment=value.getAsJsonObject("multiplied_stats_fragment");assertEquals(exact.length(),fragment.get("offset").getAsInt());exact.append(fragment.get("digits").getAsString());assertEquals(expected.length(),fragment.get("total_digits").getAsInt());}
|
||||
assertEquals(expected,exact.toString(),"Linked bounded parts must retain every original multiplied digit, not truncation/current-template reconstruction");
|
||||
}
|
||||
assertArrayEquals(definitions,Files.readAllBytes(data.resolve("dungeons.yml")));assertArrayEquals(character,Files.readAllBytes(subjectFile));
|
||||
}
|
||||
private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String... args) throws Exception {DungeonAuthoringEventsNativeTest.command(f,actor,args);}
|
||||
private static void fence(Object owner) throws Exception {((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CharacterBossPassiveHealingNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
static final class RegainDecision implements Listener {
|
||||
final boolean cancel;final List<EntityRegainHealthEvent> calls=new ArrayList<>();final List<Double> requests=new ArrayList<>();
|
||||
RegainDecision(boolean cancel){this.cancel=cancel;}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void regain(EntityRegainHealthEvent event){
|
||||
if(event.getEntity().getType()!=org.bukkit.entity.EntityType.VINDICATOR)return;
|
||||
calls.add(event);requests.add(event.getAmount());event.setAmount(4);event.setCancelled(cancel);
|
||||
}
|
||||
}
|
||||
private void settings()throws Exception{Files.writeString(data.resolve("config.yml"),"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 120\nout-of-combat-healing-amount: 2\nquicker-regeneration-i-bonus-healing: 3\n");}
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void nativeOutOfCombatRegainWithEquippedBonusCapturesOriginalDecisionAndAppliedDelta(boolean cancelled)throws Exception{
|
||||
settings();try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();var decision=new RegainDecision(cancelled);b.f.dispatcher.register(decision,b.f.plugin);b.shared.nativeSpawnOutcome=entity->{if(entity.getType()==org.bukkit.entity.EntityType.VINDICATOR)entity.setHealth(50);};b.enter(b.boss);var shell=fixture.shell();
|
||||
assertEquals(1,decision.calls.size(),"Actual native OOC callback, not a cast or synthetic event");assertEquals(cancelled?50:54,shell.health[0]);
|
||||
assertEquals(fixture.template,b.directory.custody(fixture.template.id()).orElseThrow().character());
|
||||
settle(b);var healed=owned("combat.healed");assertEquals(1,healed.size(),"Native OOC regain has its independent character-boss system owner");var row=healed.getFirst();var facts=row.getAsJsonObject("data");
|
||||
assertEquals("system",facts.get("actor_kind").getAsString());assertFalse(row.has("playerid"));assertFalse(facts.has("session_id"));assertEquals(fixture.template.id().toString(),facts.get("subject_character_id").getAsString());assertEquals(fixture.custody.operation().toString(),facts.get("custody_operation_id").getAsString());assertEquals(shell.entity.getUniqueId().toString(),facts.get("native_entity_id").getAsString());assertEquals(shell.entity.getUniqueId().toString(),facts.get("target_entity_id").getAsString());assertEquals("quicker_regeneration_i",facts.get("passive_id").getAsString());assertEquals("out_of_combat_recovery",facts.get("healing_source").getAsString());assertEquals(5,facts.get("requested_amount").getAsDouble());assertEquals(4,facts.get("native_event_amount").getAsDouble());assertEquals(cancelled?0:4,facts.get("applied_amount").getAsDouble());assertEquals(cancelled?"cancelled":"accepted",facts.get("outcome").getAsString());assertEquals(50,facts.get("health_before").getAsDouble());assertEquals(cancelled?50:54,facts.get("health_after").getAsDouble());assertTrue(facts.has("source_location"));assertTrue(facts.has("target_location"));assertTrue(owned("ability.cast").isEmpty());
|
||||
b.shared.pulse();settle(b);assertEquals(1,decision.calls.size(),"Interval does not dispatch another regain callback");assertEquals(1,owned("combat.healed").size(),"An unchanged callback interval is not a second heal");
|
||||
}
|
||||
}
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void bothEquippedRegenerationBonusesKeepOriginalCombinedNativeRequestAndQualifiedComponentProvenance(boolean cancelled)throws Exception{
|
||||
settings();Files.writeString(data.resolve("config.yml"),"quicker-regeneration-ii-bonus-healing: 4\n",java.nio.file.StandardOpenOption.APPEND);
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data,List.of("quicker_regeneration_i","quicker_regeneration_ii","low_light_vision"))){var b=fixture.base;fixture.authorAndAccept();var decision=new RegainDecision(cancelled);b.f.dispatcher.register(decision,b.f.plugin);b.shared.nativeSpawnOutcome=entity->{if(entity.getType()==org.bukkit.entity.EntityType.VINDICATOR)entity.setHealth(50);};b.enter(b.boss);var shell=fixture.shell();assertEquals(List.of(9d),decision.requests);assertEquals(cancelled?50:54,shell.health[0]);assertEquals(fixture.template,b.directory.custody(fixture.template.id()).orElseThrow().character());settle(b);var healed=owned("combat.healed");assertEquals(1,healed.size());var facts=healed.getFirst().getAsJsonObject("data");assertEquals(9,facts.get("requested_amount").getAsDouble());assertEquals(cancelled?0:4,facts.get("applied_amount").getAsDouble());assertTrue(facts.has("recovery_base_amount"),"Actual I+II recovery needs captured original base/combined bonus facts, not I-only provenance");assertEquals("2",facts.get("recovery_base_amount").getAsString());assertEquals("7",facts.get("recovery_bonus_amount").getAsString());assertEquals(List.of("quicker_regeneration_i","quicker_regeneration_ii"),java.util.stream.StreamSupport.stream(facts.getAsJsonArray("recovery_bonus_passive_ids").spliterator(),false).map(com.google.gson.JsonElement::getAsString).toList());assertEquals("unavailable_from_combined_native_bonus_port",facts.get("bonus_component_amounts_status").getAsString());assertFalse(facts.has("quicker_regeneration_i_amount"));assertFalse(facts.has("quicker_regeneration_ii_amount"));b.shared.pulse();settle(b);assertEquals(1,decision.requests.size());assertEquals(1,owned("combat.healed").size());}
|
||||
}
|
||||
@Test void fullHealthAndUnharmedNativeAiDoesNotInventHealingCleansingOrBond()throws Exception{
|
||||
settings();try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var decision=new RegainDecision(false);b.f.dispatcher.register(decision,b.f.plugin);b.actor.damage=(amount,source)->{};
|
||||
fixture.damage(b.actor,shell,2,false);b.shared.pulse();settle(b);
|
||||
assertEquals(70,shell.health[0]);assertTrue(decision.calls.isEmpty());assertTrue(owned("combat.healed").isEmpty());assertTrue(owned("combat.damage").isEmpty(),"No dispatched native damage can become a successful combat row");assertTrue(owned("effect.applied").stream().noneMatch(row->"purify".equals(row.getAsJsonObject("data").has("ability_id")?row.getAsJsonObject("data").get("ability_id").getAsString():null)));
|
||||
var resolved=owned("ability.resolved");for(var id:List.of("second_wind","purify")){var matches=resolved.stream().filter(row->id.equals(row.getAsJsonObject("data").get("ability_id").getAsString())).toList();assertEquals(1,matches.size());assertEquals("no_op",matches.getFirst().getAsJsonObject("data").get("outcome").getAsString());assertFalse(matches.getFirst().getAsJsonObject("data").get("cooldown_committed").getAsBoolean());}
|
||||
assertTrue(owned("ability.cast").stream().noneMatch(row->"guardian_bond".equals(row.getAsJsonObject("data").get("ability_id").getAsString())));
|
||||
b.shared.pulse();settle(b);assertTrue(decision.calls.isEmpty());assertTrue(owned("combat.healed").isEmpty());for(var id:List.of("second_wind","purify")){var retries=owned("ability.resolved").stream().filter(row->id.equals(row.getAsJsonObject("data").get("ability_id").getAsString())).toList();assertEquals(2,retries.size(),"Refused full-health/no-harmful-effect attempt does not commit a cooldown");assertTrue(retries.stream().noneMatch(row->row.getAsJsonObject("data").get("cooldown_committed").getAsBoolean()));}assertEquals(fixture.template,b.directory.custody(fixture.template.id()).orElseThrow().character());int casts=owned("ability.cast").size();org.mockito.Mockito.doReturn(false).when(shell.entity).hasLineOfSight(b.actor.player);b.shared.pulse();settle(b);assertEquals(casts,owned("ability.cast").size(),"Unreachable target cannot manufacture another native AI cast");assertTrue(owned("combat.damage").isEmpty());assertTrue(owned("combat.healed").isEmpty());
|
||||
}
|
||||
}
|
||||
private List<com.google.gson.JsonObject> owned(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)&&row.getAsJsonObject("data").has("entity_role")&&row.getAsJsonObject("data").get("entity_role").getAsString().equals("character_boss")).toList();}
|
||||
private static void settle(DungeonAuditNativeFixture b)throws Exception{fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));}
|
||||
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CharacterBossRuntimeEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void registeredDistinctTemplateAssignmentAndActualGiverAcceptanceMaterializeMultipliedShellAsSystemActorNotFantasyPlayer()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characters");var before=storage.find(fixture.template.id()).get().orElseThrow();b.enter(b.boss);var shell=fixture.shell();assertTrue(shell.entity.isValid());assertEquals(70,shell.maximum[0]);assertEquals(26,shell.attack[0]);assertEquals(70,shell.health[0]);assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertEquals(before,storage.find(fixture.template.id()).get().orElseThrow());assertTrue(b.directory.runView(b.definition.id(),b.quest.catalog()).orElseThrow().defeatedSlots().isEmpty());fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));
|
||||
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("dungeon.entity.spawned")&&"character_boss".equals(row.getAsJsonObject("data").has("entity_role")?row.getAsJsonObject("data").get("entity_role").getAsString():null)).toList();assertEquals(1,rows.size(),"Actual loaded-valid multiplied character shell needs its independent system-owned variant, not generic run/native-boss/player evidence");var row=rows.getFirst();var values=row.getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertFalse(row.has("playerid"));assertFalse(row.has("characterid"));assertFalse(values.has("session_id"));assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(fixture.template.name(),values.get("subject_character_name").getAsString());assertEquals(fixture.custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals(shell.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals("VINDICATOR",values.get("entity_type").getAsString());assertEquals(b.definition.id().toString(),values.get("dungeon_id").getAsString());assertEquals(b.boss.id().toString(),values.get("room_id").getAsString());assertEquals(b.quest.catalog().toString(),values.get("generation_id").getAsString());assertEquals("2",values.get("multiplier").getAsString());assertEquals("60",values.getAsJsonObject("multiplied_stats").get("CON").getAsString());assertEquals(17.25,values.getAsJsonObject("spawn_location").get("x").getAsDouble());assertEquals("loaded_valid_registered_entity",values.get("materialization_status").getAsString());assertFalse(values.has("character_snapshot"));assertFalse(values.has("item"));assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(record->record.get("type").getAsString().endsWith("combat.reward_resolved")||record.get("type").getAsString().endsWith("dungeon.completed")));b.f.plugin.setEnabled(false);b.f.awaitWorkers();WitchAuditTestSupport.drain(b.f,"characterBossDrain");
|
||||
}
|
||||
}
|
||||
@Test void realEmptyRoomRemovalAndReentryUseOriginalShellFactsWithoutRewardOrCustodyMutation()throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var original=fixture.shell();b.actor.core.location.setX(100);b.shared.pulse();assertFalse(original.entity.isValid(),"The actual native empty-room cleanup removed the original shell");assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertFalse(b.directory.runView(b.definition.id(),b.quest.catalog()).orElseThrow().defeated());settle(b);
|
||||
var removed=owned("dungeon.entity.removed");assertEquals(1,removed.size(),"Real character-shell cleanup needs its own nonreward system observation, not ordinary-boss cleanup or silence");var values=removed.getFirst().getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertEquals(original.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(fixture.custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals("no_eligible_player_in_room",values.get("reason").getAsString());assertEquals(17.25,values.getAsJsonObject("removal_location").get("x").getAsDouble());assertFalse(removed.getFirst().has("playerid"));assertFalse(values.has("session_id"));
|
||||
b.enter(b.boss);var fresh=fixture.shell();assertNotEquals(original.entity.getUniqueId(),fresh.entity.getUniqueId());assertEquals(70,fresh.health[0]);assertNull(fresh.target[0]);settle(b);assertEquals(2,owned("dungeon.entity.spawned").size());assertEquals(1,owned("dungeon.entity.removed").size());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")));
|
||||
}
|
||||
}
|
||||
@Test void genuineRepresentedNativeShellDeathObservesAcknowledgedDefeatWithoutCopyingSoleKillCompletionOrTrophyOwner()throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var source=org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class);org.mockito.Mockito.when(source.getCausingEntity()).thenReturn(b.actor.player);org.mockito.Mockito.when(source.getDirectEntity()).thenReturn(b.actor.player);b.f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.actor.player,shell.entity,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));b.f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(shell.entity,source,new java.util.ArrayList<>(),0));ReskillEventsNativeTest.await(b.f,()->b.directory.runView(b.definition.id(),b.quest.catalog()).map(games.dmg.fantasystick.dungeon.DungeonState.Run::defeated).orElse(false));ReskillEventsNativeTest.await(b.f,()->{try{var rows=ReskillEventsNativeTest.records(data);return rows.stream().anyMatch(row->row.get("type").getAsString().endsWith("dungeon.completed"))&&rows.stream().anyMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved"));}catch(Exception pending){return false;}});assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertFalse(b.directory.runView(b.definition.id(),b.quest.catalog()).orElseThrow().clearedRooms().contains(b.entry.id()),"Actual boss death completes without fabricated ordinary-room kills");settle(b);
|
||||
var defeated=owned("dungeon.entity.defeated");assertEquals(1,defeated.size(),"Actual acknowledged native character-shell defeat needs its own original system observation, not copied reward/completion evidence");var values=defeated.getFirst().getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertEquals(shell.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(b.quest.catalog().toString(),values.get("generation_id").getAsString());assertEquals("acknowledged_boss_completion",values.get("settlement_status").getAsString());assertEquals(b.actor.core.id.toString(),values.get("triggering_player_id").getAsString());assertEquals(17.25,values.getAsJsonObject("death_location").get("x").getAsDouble());assertFalse(defeated.getFirst().has("characterid"));assertFalse(values.has("session_id"));assertFalse(values.has("xp"));assertFalse(values.has("coins"));assertTrue(owned("dungeon.entity.removed").isEmpty(),"The genuinely defeated shell is not also an empty-room survivor");var rows=ReskillEventsNativeTest.records(data);assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("combat.reward_resolved")&&row.getAsJsonObject("data").get("target_id").getAsString().equals(shell.entity.getUniqueId().toString())).count());assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("dungeon.completed")).count());assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.trophy_earned")&&"dungeon_trophy".equals(row.getAsJsonObject("data").get("trophy_type").getAsString())).count());assertEquals(fixture.template,((games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characters")).find(fixture.template.id()).get().orElseThrow());
|
||||
}
|
||||
}
|
||||
private java.util.List<com.google.gson.JsonObject> owned(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)&&row.getAsJsonObject("data").has("entity_role")&&row.getAsJsonObject("data").get("entity_role").getAsString().equals("character_boss")).toList();}
|
||||
private static void settle(DungeonAuditNativeFixture b)throws Exception{Runnable reply;while((reply=b.f.tasks.poll())!=null)reply.run();fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));}
|
||||
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.events.CharacterBossEventBridge;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class CharacterBossRuntimeReplayNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void rejectedWriterOriginalRuntimeAiNativeOutcomesAndLifecycleReplayTwiceOfflineWithoutRecreatingAuthority(boolean cancelledAndDefeated) throws Exception {
|
||||
List<Properties> originals;
|
||||
UUID subject;
|
||||
games.dmg.fantasystick.persistence.CharacterRecord template;
|
||||
games.dmg.fantasystick.dungeon.DungeonState originalState;
|
||||
var bytes=new LinkedHashMap<Path,byte[]>();
|
||||
FantasyStickLifecycleNativeTest.Actor originalActor;
|
||||
org.bukkit.inventory.ItemStack[] originalInventory;
|
||||
int originalMessages;
|
||||
java.util.List<org.bukkit.entity.LivingEntity> originalSpawns;
|
||||
int spawnCount;
|
||||
var folder=data.resolve("character-boss-event-intents");
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)) {
|
||||
var b=fixture.base;
|
||||
fixture.authorAndAccept();
|
||||
ReskillEventsNativeTest.await(b.f,()->{try{return ReskillEventsNativeTest.records(data).stream().anyMatch(row->row.get("type").getAsString().endsWith("dungeon.character_boss.assigned")&&row.has("playerid")&&row.get("playerid").getAsString().equals(b.actor.core.id.toString()));}catch(Exception failure){throw new AssertionError(failure);}});
|
||||
fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));
|
||||
subject=fixture.template.id();template=fixture.template;
|
||||
((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog")).close();
|
||||
b.enter(b.boss);var shell=fixture.shell();shell.entity.setHealth(50);
|
||||
var nativeDamage=new ArrayList<EntityDamageByEntityEvent>();
|
||||
b.actor.damage=(amount,causing)->{
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getCausingEntity()).thenReturn(causing);when(source.getDirectEntity()).thenReturn(causing);
|
||||
var event=new EntityDamageByEntityEvent(causing,b.actor.player,EntityDamageEvent.DamageCause.MAGIC,source,amount);
|
||||
event.setCancelled(cancelledAndDefeated);b.f.dispatcher.accept(event);nativeDamage.add(event);
|
||||
};
|
||||
var nativePotions=new ArrayList<EntityPotionEffectEvent>();
|
||||
CharacterBossEffectEventsNativeTest.nativePotionPort(b,b.actor.player,b.playerEffects,shell.entity,cancelledAndDefeated,nativePotions,Set.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS));
|
||||
CharacterBossEffectEventsNativeTest.nativePotionPort(b,shell.entity,b.nativeEffects.get(shell.entity.getUniqueId()),shell.entity,cancelledAndDefeated,nativePotions,Set.of(PotionEffectType.RESISTANCE));
|
||||
b.nativeEffects.get(shell.entity.getUniqueId()).put(PotionEffectType.WEAKNESS,new PotionEffect(PotionEffectType.WEAKNESS,200,0));
|
||||
var healing=new CharacterBossAbilityEventsNativeTest.NativeHealingGuard(cancelledAndDefeated);
|
||||
b.f.dispatcher.register(healing,b.f.plugin);
|
||||
fixture.damage(b.actor,shell,3,false);b.shared.pulse();
|
||||
assertEquals(2,nativeDamage.size());assertEquals(3,nativePotions.size());assertEquals(1,healing.observed.size());assertEquals(cancelledAndDefeated?50:55,shell.health[0]);
|
||||
if(!cancelledAndDefeated) {
|
||||
for(var type:List.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS)) {
|
||||
var old=b.playerEffects.get(type);
|
||||
b.f.dispatcher.accept(new EntityPotionEffectEvent(b.actor.player,old,null,shell.entity,EntityPotionEffectEvent.Cause.EXPIRATION,EntityPotionEffectEvent.Action.REMOVED,false));
|
||||
b.playerEffects.remove(type);
|
||||
}
|
||||
}
|
||||
settle(b);
|
||||
b.actor.core.location.setX(100);b.shared.pulse();assertFalse(shell.entity.isValid());settle(b);
|
||||
b.enter(b.boss);var fresh=fixture.shell();assertNotEquals(shell.entity.getUniqueId(),fresh.entity.getUniqueId());assertEquals(70,fresh.health[0]);assertNull(fresh.target[0]);
|
||||
if(cancelledAndDefeated) {
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(b.actor.player);when(source.getDirectEntity()).thenReturn(b.actor.player);
|
||||
b.f.dispatcher.accept(new EntityDamageByEntityEvent(b.actor.player,fresh.entity,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));
|
||||
b.f.dispatcher.accept(new EntityDeathEvent(fresh.entity,source,new ArrayList<>(),0));
|
||||
ReskillEventsNativeTest.await(b.f,()->b.directory.runView(b.definition.id(),b.quest.catalog()).map(games.dmg.fantasystick.dungeon.DungeonState.Run::defeated).orElse(false));
|
||||
}
|
||||
assertEquals(fixture.custody,b.directory.custody(subject).orElseThrow());
|
||||
assertEquals(template,((CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characters")).find(subject).get().orElseThrow());
|
||||
b.f.plugin.setEnabled(false);b.f.awaitWorkers();WitchAuditTestSupport.drain(b.f,"characterBossDrain");
|
||||
originals=WitchAuditTestSupport.intents(folder);
|
||||
var counts=originals.stream().collect(java.util.stream.Collectors.groupingBy(p->p.getProperty("type"),java.util.stream.Collectors.counting()));
|
||||
for(var type:List.of("dungeon.entity.spawned","dungeon.entity.removed","dungeon.character_boss.target_changed","ability.cast","ability.resolved","combat.damage","combat.healed","effect.applied","effect.ended")) assertTrue(counts.containsKey(type),"Actual original runtime intent missing: "+type);
|
||||
assertEquals(cancelledAndDefeated?1L:0L,counts.getOrDefault("dungeon.entity.defeated",0L));
|
||||
assertEquals(2L,counts.get("combat.damage"));assertEquals(1L,counts.get("combat.healed"));
|
||||
for(var intent:originals){assertEquals("ssystem",intent.getProperty("data.actor_kind"));assertNull(intent.getProperty("player"));assertNull(intent.getProperty("character"));assertNull(intent.getProperty("session"));assertNull(intent.getProperty("data.session_id"));}
|
||||
originalState=new games.dmg.fantasystick.persistence.YamlDungeonRepository(data).load();
|
||||
originalActor=b.actor;originalInventory=java.util.Arrays.stream(b.actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);originalMessages=b.actor.messages.size();originalSpawns=b.shared.spawned;spawnCount=originalSpawns.size();
|
||||
try(var paths=Files.walk(data)) {
|
||||
for(var path:paths.filter(p->Files.isRegularFile(p)&&p.toString().endsWith(".yml")&&!p.getFileName().toString().equals("config.yml")).toList())bytes.put(data.relativize(path),Files.readAllBytes(path));
|
||||
}
|
||||
}
|
||||
// Reopen only the independent owner/writer: a full plugin restart separately resumes US-085's
|
||||
// legitimate pending completion publication, and cannot isolate this owner's byte conservation.
|
||||
var failures=new ArrayList<Throwable>();
|
||||
try(var writer=LocalEventLog.open(data,java.time.Clock.systemUTC(),new LocalEventLog.RuntimeInfo("offline metadata","pinned-native","26.2","test"),128,1_048_576,message->{throw new AssertionError(message);})) {
|
||||
writer.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var audit=new CharacterBossEventBridge(writer::emit,data,sender->{throw new AssertionError("Offline replay must not capture current actor/native authority");},failures::add);
|
||||
try(audit) {audit.replay();audit.replay();}
|
||||
audit.drained().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
}
|
||||
assertTrue(failures.isEmpty(),failures.toString());
|
||||
var rows=ReskillEventsNativeTest.records(data);
|
||||
for(var intent:originals){var matching=rows.stream().filter(row->row.get("id").getAsString().equals(intent.getProperty("id"))).toList();assertEquals(1,matching.size());WitchAuditTestSupport.assertOriginal(intent,matching.getFirst());assertEquals("system",matching.getFirst().getAsJsonObject("data").get("actor_kind").getAsString());assertFalse(matching.getFirst().has("playerid"));assertFalse(matching.getFirst().has("characterid"));assertFalse(matching.getFirst().getAsJsonObject("data").has("session_id"));}
|
||||
assertEquals(originalState,new games.dmg.fantasystick.persistence.YamlDungeonRepository(data).load());
|
||||
assertEquals(template,originalState.dungeons().getFirst().rooms().stream().filter(room->room.boss()!=null&&room.boss().character()!=null).findFirst().orElseThrow().boss().character().character());
|
||||
for(var entry:bytes.entrySet())assertArrayEquals(entry.getValue(),Files.readAllBytes(data.resolve(entry.getKey())),"Replay cannot rewrite original authority: "+entry.getKey());
|
||||
assertArrayEquals(originalInventory,originalActor.core.inventory.getContents());assertEquals(originalMessages,originalActor.messages.size());assertEquals(spawnCount,originalSpawns.size(),"Independent offline audit replay cannot materialize any encounter");
|
||||
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")),"Independent character owner cannot export another owner's original receipts or re-earn rewards");
|
||||
}
|
||||
private static void settle(DungeonAuditNativeFixture b) throws Exception {
|
||||
Runnable reply;while((reply=b.f.tasks.poll())!=null)reply.run();
|
||||
fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));
|
||||
}
|
||||
private static void fence(Object owner) throws Exception {((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CharacterBossTargetEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void realAcceptedThreatFixatesDespiteLaterHigherThreatThenRetargetsWithOriginalNativeSystemFactsAndNoPulseSpam()throws Exception{
|
||||
try(var fixture=new CharacterBossAuditNativeFixture(data)){
|
||||
var b=fixture.base;fixture.authorAndAccept();var challenger=fixture.acceptedChallenger(data);b.actor.damage=(amount,source)->{};b.enter(b.boss);var shell=fixture.shell();fixture.damage(b.actor,shell,100,true);b.shared.pulse();assertNull(shell.target[0],"Prior native cancellation cannot establish accepted threat or a fantasy target");fixture.damage(b.actor,shell,3,false);b.shared.pulse();assertSame(b.actor.player,shell.target[0]);fixture.damage(challenger,shell,10,false);b.shared.pulse();assertSame(b.actor.player,shell.target[0],"Later higher accepted threat cannot steal actual fixation");b.actor.core.location.setX(100);b.shared.pulse();assertSame(challenger.player,shell.target[0],"Actual ineligible fixation retargets highest remaining eligible threat");for(int i=0;i<3;i++)b.shared.pulse();assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("dungeon.character_boss.target_changed")).toList();assertEquals(2,rows.size(),"Actual initial threat fixation and ineligible retarget need original system-owned transitions, not pulse/refusal spam or later actor reconstruction");
|
||||
var initial=rows.stream().filter(row->row.getAsJsonObject("data").get("target_player_id").getAsString().equals(b.actor.core.id.toString())).findFirst().orElseThrow();var retarget=rows.stream().filter(row->row.getAsJsonObject("data").get("target_player_id").getAsString().equals(challenger.core.id.toString())).findFirst().orElseThrow();assertEquals("highest_accepted_threat",initial.getAsJsonObject("data").get("reason").getAsString());assertEquals("fixation_invalidated_retarget",retarget.getAsJsonObject("data").get("reason").getAsString());assertEquals(b.actor.core.record.id().toString(),initial.getAsJsonObject("data").get("target_character_id").getAsString());assertEquals(challenger.core.record.id().toString(),retarget.getAsJsonObject("data").get("target_character_id").getAsString());assertEquals(17.5,initial.getAsJsonObject("data").getAsJsonObject("target_location").get("x").getAsDouble());assertEquals(17.75,retarget.getAsJsonObject("data").getAsJsonObject("target_location").get("x").getAsDouble());assertEquals(3,initial.getAsJsonObject("data").get("accepted_threat").getAsDouble());assertEquals(10,retarget.getAsJsonObject("data").get("accepted_threat").getAsDouble());assertEquals(b.actor.core.id.toString(),retarget.getAsJsonObject("data").get("previous_target_player_id").getAsString());for(var row:rows){var values=row.getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(shell.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals(fixture.custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals(b.quest.catalog().toString(),values.get("generation_id").getAsString());assertEquals(17.25,values.getAsJsonObject("source_location").get("x").getAsDouble());assertFalse(values.has("session_id"));assertFalse(row.has("playerid"));assertFalse(row.has("characterid"));}
|
||||
}
|
||||
}
|
||||
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
|
||||
}
|
||||
@@ -43,13 +43,15 @@ final class DungeonAuditNativeFixture implements AutoCloseable {
|
||||
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{
|
||||
@FunctionalInterface interface BossSetup{void configure()throws Exception;}
|
||||
void authorAndAccept(String effectTarget,boolean ordinarySlot)throws Exception{authorAndAccept(effectTarget,ordinarySlot,()->{});}
|
||||
void authorAndAccept(String effectTarget,boolean ordinarySlot,BossSetup characterBoss)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");
|
||||
boss=directory.find(id).orElseThrow().rooms().getLast();command("dungeon","room","boss","mob","vindicator","Original Keeper");characterBoss.configure();
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user