diff --git a/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java b/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java index 6a9f74c..8c06447 100644 --- a/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java +++ b/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java @@ -171,6 +171,7 @@ public final class FantasyStickPlugin extends JavaPlugin { private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents; private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents; private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents; + private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents; private java.util.concurrent.CompletionStage configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null); @Override public void onEnable() { @@ -713,13 +714,14 @@ public final class FantasyStickPlugin extends JavaPlugin { abilityEvents = new games.dmg.fantasystick.events.AbilityEventBridge(eventLog::emit, Clock.systemUTC());books.attachAbilityEvents(abilityEvents); passiveEvents = new games.dmg.fantasystick.events.PassiveEventBridge(eventLog::emit, Clock.systemUTC());books.attachPassiveEvents(passiveEvents);admin.attachPassiveEvents(passiveEvents); recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(eventLog::emit, Clock.systemUTC());outOfCombatRecovery.attachEvents(recoveryEvents,record->record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)?quickerRegeneration.bonus():java.math.BigInteger.ZERO); + reviveEvents = new games.dmg.fantasystick.events.ReviveEventBridge(eventLog::emit, Clock.systemUTC());revive.attachEvents(reviveEvents); sessions.attachDownedEvents(downedEvents); downedController.attachEvents(downedEvents); revive.attachDownedEvents(downedEvents); admin.attachDownedEvents(downedEvents); getServer().getPluginManager().registerEvents(eventCapture, this); } catch (java.io.IOException failure) { - eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; + eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure); } getServer().getPluginManager().registerEvents(crafting, this); @@ -822,7 +824,7 @@ public final class FantasyStickPlugin extends JavaPlugin { combatFeedback = null; var closingEvents = sessionEvents; sessionEvents = null; var closingEventLog = eventLog; eventLog = null; - var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; + var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; var closingBooks = books; books = null; var closingPartyEvents = partyEvents; partyEvents = null; var closingPartyCombat = partyCombat; partyCombat = null; diff --git a/src/main/java/games/dmg/fantasystick/ability/Revive.java b/src/main/java/games/dmg/fantasystick/ability/Revive.java index 7b5b20c..399943f 100644 --- a/src/main/java/games/dmg/fantasystick/ability/Revive.java +++ b/src/main/java/games/dmg/fantasystick/ability/Revive.java @@ -52,6 +52,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { private final CharacterSheets sheets = new CharacterSheets(); private games.dmg.fantasystick.session.DownedController downed; private games.dmg.fantasystick.events.DownedEventBridge downedEvents; + private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents; private java.util.function.Supplier downedProtection=()->Duration.ofSeconds(5); private final Map inventories = new IdentityHashMap<>(); private final Map targets = new LinkedHashMap<>(); @@ -62,16 +63,22 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { final Player target; final Inventory inventory; final Map choices; + final games.dmg.fantasystick.events.ReviveEventBridge.Cast cast; boolean selecting; + boolean eventResolved; boolean finished; - View(AbilityExecution execution, Player target, Inventory inventory, Map choices) { - this.execution = execution; this.target = target; this.inventory = inventory; this.choices = choices; + View(AbilityExecution execution, Player target, Inventory inventory, Map choices,games.dmg.fantasystick.events.ReviveEventBridge.Cast cast) { + this.execution = execution; this.target = target; this.inventory = inventory; this.choices = choices;this.cast=cast; } + void resolve(String outcome,String stage){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.resolved(cast,outcome,stage);}} + void fail(String reason){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.failed(cast,reason);}} + void revived(RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.revived(cast,reservation,dead,restored,target);}} @Override public void close() { if (finished) return; finished = true; inventories.remove(inventory, this); targets.remove(target.getUniqueId(), this); inventory.clear(); + if(!selecting)resolve("cancelled_or_expired","tail_selection_closed"); try { target.closeInventory(); } catch (RuntimeException failure) { report(failure); } } } @@ -85,6 +92,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { public void attachDowned(games.dmg.fantasystick.session.DownedController controller,java.util.function.Supplier protection){if(closed||downed!=null)throw new IllegalStateException("Downed Revive already attached or closed");downed=Objects.requireNonNull(controller);downedProtection=Objects.requireNonNull(protection);} public void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(closed||downedEvents!=null)throw new IllegalStateException("Downed Revive events already attached or closed");downedEvents=Objects.requireNonNull(events);} + public void attachEvents(games.dmg.fantasystick.events.ReviveEventBridge events){if(closed||reviveEvents!=null)throw new IllegalStateException("Revive events already attached or closed");reviveEvents=Objects.requireNonNull(events);} public AbilityDefinition definition() { return new AbilityDefinition("revive", "Revive", Material.ECHO_SHARD, @@ -107,15 +115,16 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { execution.finish(); return; } if(downed!=null&&downed.downed(target)){var episode=downedEpisode(target);if(episode==null){execution.finish();return;}var action=UUID.randomUUID();var caster=execution.character();var cooldown=caster.cooldowns().get("revive");var distance=execution.input().player().getLocation().distance(target.getLocation());var castAt=clock.instant();var decision=new games.dmg.fantasystick.session.DownedEpisode.Decision("revive",execution.input().player().getUniqueId(),execution.input().player().getName(),caster.id(),caster.name(),execution.input().session().session(),action,castAt,cooldown,distance,true);if(downedEvents!=null)downedEvents.abilityCast(episode,decision);downed.revive(target,downedProtection.get(),decision,status->{if(downedEvents!=null&&status!=games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED&&status!=games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED_PENDING_RECOVERY)downedEvents.abilityResolved(episode,decision,status.name().toLowerCase(java.util.Locale.ROOT),clock.instant());execution.input().player().sendMessage(switch(status){case APPLIED->"Revive restored the downed character in place.";case APPLIED_PENDING_RECOVERY->"Revive restored the downed character; durable publication is pending recovery.";case EXPIRED->"Revive arrived after the downed countdown expired.";case STALE->"That downed episode was already resolved or changed.";case FAILED->"Downed Revive is pending recovery; no second rescue was attempted.";});execution.finish();});return;} + var cast=reviveEvents==null?null:reviveEvents.cast(execution,target); var captured = candidates(target); if (captured.isEmpty()) { - execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive."); + if(cast!=null)reviveEvents.resolved(cast,"no_eligible_tail","tail_scan");execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive."); execution.finish(); return; } var loads = captured.stream().map(candidate -> characters.find(candidate.identity().characterId())).toList(); CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> schedule(() -> { - if (failure != null) { report(failure); execution.input().player().sendMessage("Revive could not inspect those Tails safely."); execution.finish(); return; } - if (closed || !execution.stillCurrent() || !target.isOnline()) { execution.finish(); return; } + if (failure != null) { if(cast!=null)reviveEvents.failed(cast,"tail_inspection_failed");report(failure); execution.input().player().sendMessage("Revive could not inspect those Tails safely."); execution.finish(); return; } + if (closed || !execution.stillCurrent() || !target.isOnline()) {if(cast!=null)reviveEvents.resolved(cast,"stale","tail_inspection"); execution.finish(); return; } var eligible = new ArrayList(); Instant now = clock.instant(); for (int index = 0; index < captured.size(); index++) { @@ -126,10 +135,10 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { if (eligible(record, candidate.identity(), now) && unchanged(target, candidate)) eligible.add(new Choice(candidate, record)); } if (eligible.isEmpty()) { - execution.input().player().sendMessage("That player has no current Tail younger than 24 hours."); + if(cast!=null)reviveEvents.resolved(cast,"expired_or_stale","tail_eligibility");execution.input().player().sendMessage("That player has no current Tail younger than 24 hours."); execution.finish(); return; } - open(execution, target, eligible, now); + open(execution, target, eligible, now,cast); })); } @@ -174,7 +183,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { return current != null && current.equals(candidate.captured()); } - private void open(AbilityExecution execution, Player target, List eligible, Instant now) { + private void open(AbilityExecution execution, Player target, List eligible, Instant now,games.dmg.fantasystick.events.ReviveEventBridge.Cast cast) { View old = targets.get(target.getUniqueId()); if (old != null) old.execution.finish(); int size = Math.min(54, Math.max(9, ((eligible.size() + 8) / 9) * 9)); Inventory inventory = popups.create(size, Component.text("Choose a Tail to Revive")); @@ -187,7 +196,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { Component.text("Eligible for " + seconds + " more seconds"), Component.text("Click to continue this character"))); display.setItemMeta(meta); inventory.setItem(index, display); choices.put(index, choice); } - var view = new View(execution, target, inventory, Map.copyOf(choices)); + var view = new View(execution, target, inventory, Map.copyOf(choices),cast); inventories.put(inventory, view); targets.put(target.getUniqueId(), view); execution.own(view); target.openInventory(inventory); execution.later(VIEW_TIME, execution::finish); @@ -231,14 +240,14 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { private void confirm(View view, Choice choice) { if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || !unchanged(view.target, choice.candidate())) { - view.execution.finish(); return; + view.resolve("stale","tail_confirmation");view.execution.finish(); return; } characters.find(choice.record().id()).whenComplete((found, failure) -> schedule(() -> { - if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive could not recheck that Tail; no item changed."); view.execution.finish(); return; } + if (failure != null) {view.fail("tail_recheck_failed"); report(failure); view.execution.input().player().sendMessage("Revive could not recheck that Tail; no item changed."); view.execution.finish(); return; } if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || found.isEmpty() || !eligible(found.orElseThrow(), choice.candidate().identity(), clock.instant()) || !unchanged(view.target, choice.candidate())) { - view.execution.input().player().sendMessage("That Tail is no longer eligible; no item changed."); + view.resolve("expired_or_stale","tail_confirmation");view.execution.input().player().sendMessage("That Tail is no longer eligible; no item changed."); view.execution.finish(); return; } reserve(view, choice.candidate(), found.orElseThrow()); @@ -249,17 +258,17 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { ItemStack source = view.target.getInventory().getItem(candidate.slot()); int destination = source.getAmount() == 1 ? -1 : emptyDirectSlot(view.target, candidate.slot()); if (source.getAmount() > 1 && destination < 0) { - view.target.sendMessage("Revive needs one empty direct inventory slot to preserve the copied Tails."); + view.resolve("unsafe_capacity","tail_reservation");view.target.sendMessage("Revive needs one empty direct inventory slot to preserve the copied Tails."); view.execution.finish(); return; } var reservation = new RevivalStore.Reservation(UUID.randomUUID(), view.execution.character().id(), view.target.getUniqueId(), dead.id(), dead.deathId(), clock.instant().truncatedTo(ChronoUnit.SECONDS), candidate.slot(), source.getAmount(), destination); reservations.reserve(reservation).whenComplete((reserved, failure) -> schedule(() -> { - if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; } + if (failure != null) {view.fail("reservation_save_failed"); report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; } if (reserved.isEmpty() || closed || !view.execution.stillCurrent() || !view.target.isOnline() || !unchanged(view.target, candidate)) { - view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed."); + view.resolve("stale","tail_reservation");view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed."); view.execution.finish(); return; } deliver(view, dead, reservation); @@ -290,22 +299,22 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { } view.target.updateInventory(); } catch (RuntimeException uncertain) { - report(uncertain); view.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery."); + view.fail("native_tail_delivery_uncertain");report(uncertain); view.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery."); view.execution.finish(); return; } var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read(); characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit) .whenComplete((restored, failure) -> schedule(() -> { if (failure != null || restored.isEmpty()) { - if (failure != null) report(failure); + view.fail(failure!=null?"character_restore_failed":"character_restore_unavailable");if (failure != null) report(failure); view.target.sendMessage("The Tail changed, but character restoration is pending recovery; do not move the sheet."); view.execution.finish(); return; } if (installFinalSheet(view.target, restored.orElseThrow())) { - reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; }); + view.revived(reservation,dead,restored.orElseThrow());reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; }); view.target.sendMessage("Restored character sheet: " + restored.orElseThrow().name() + "."); view.execution.input().player().sendMessage("Revive restored " + restored.orElseThrow().name() + "."); - } else view.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery."); + } else {view.fail("restored_but_sheet_custody_ambiguous");view.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");} view.execution.finish(); })); } @@ -349,7 +358,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { : source != null && source.getAmount() == reservation.sourceAmount() - 1 && tails.identify(source).filter(id -> id.deathId().equals(reservation.death())).isPresent(); if (!record.dead() && hasSheet && installFinalSheet(target, record)) { - reservations.clear(reservation.character(), reservation.id()); return; + if(reviveEvents!=null)reviveEvents.recovered(reservation,record,target);reservations.clear(reservation.character(), reservation.id()); return; } if (record.dead() && unchangedTail && !hasSheet) { reservations.clear(reservation.character(), reservation.id()); return; @@ -366,7 +375,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { .whenComplete((restored, failure) -> schedule(() -> { if (failure != null) { report(failure); return; } if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) { - reservations.clear(reservation.character(), reservation.id()); + if(reviveEvents!=null)reviveEvents.recovered(reservation,restored.orElseThrow(),target);reservations.clear(reservation.character(), reservation.id()); } })); } diff --git a/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java b/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java index d6707c5..36eeb52 100644 --- a/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java +++ b/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java @@ -861,9 +861,10 @@ public final class CharacterBookController implements Consumer, Au || event.getTag() == null) return; var player = connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String ability=match.group(2); var view = abilityViews.get(player.getUniqueId()); + boolean audited=unlearn||unlock&&"revive".equals(ability); if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(ability) - || !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(unlearn)refuseAbilityUnlearn(player,ability,"stale_forged_or_repeated_action");return;} - var audit=unlearn&&abilityEvents!=null?abilityEvents.begin(view.input(),view.before(),ability):null; + || !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(audited)refuseAbilityMutation(player,ability,unlock,"stale_forged_or_repeated_action");return;} + var audit=audited&&abilityEvents!=null?abilityEvents.begin(view.input(),view.before(),ability,unlock):null; var operation = unlock ? abilityEngine.purchase(view.input(), ability, view.revision()) : abilityEngine.unlearn(view.input(), ability, view.revision()); operation.whenComplete((mutation, failure) -> { @@ -879,7 +880,7 @@ public final class CharacterBookController implements Consumer, Au }); } - private void refuseAbilityUnlearn(org.bukkit.entity.Player player,String ability,String reason){if(abilityEvents==null||abilities.find(ability).isEmpty())return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)abilityEvents.refused(player,active,current,ability,reason);} + private void refuseAbilityMutation(org.bukkit.entity.Player player,String ability,boolean learn,String reason){if(abilityEvents==null||abilities.find(ability).isEmpty())return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)abilityEvents.refused(player,active,current,ability,learn,reason);} @org.bukkit.event.EventHandler public void passiveAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { diff --git a/src/main/java/games/dmg/fantasystick/events/AbilityEventBridge.java b/src/main/java/games/dmg/fantasystick/events/AbilityEventBridge.java index 7271d72..f6f03ba 100644 --- a/src/main/java/games/dmg/fantasystick/events/AbilityEventBridge.java +++ b/src/main/java/games/dmg/fantasystick/events/AbilityEventBridge.java @@ -16,36 +16,39 @@ import org.bukkit.Location; /** Feature-owned exact ability-refund outcomes; never an authorization or refund authority. */ public final class AbilityEventBridge { - public record Action(UUID actionId,EventInput.Context context,String ability,CharacterRecord before){ + public record Action(UUID actionId,EventInput.Context context,String ability,boolean learn,CharacterRecord before){ public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(ability);Objects.requireNonNull(before);} } private final Consumer sink;private final Clock clock; public AbilityEventBridge(Consumer sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);} - public Action begin(ControlInput input,CharacterRecord before,String ability){ - var action=new Action(UUID.randomUUID(),context(input.player(),input.session(),before.name()),ability,before); + public Action begin(ControlInput input,CharacterRecord before,String ability){return begin(input,before,ability,false);} + public Action begin(ControlInput input,CharacterRecord before,String ability,boolean learn){ + var action=new Action(UUID.randomUUID(),context(input.player(),input.session(),before.name()),ability,learn,before); emit("action.observed",action,"observed",null,null,null);return action; } - public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,String ability,String reason){ - var action=new Action(UUID.randomUUID(),context(player,active,before.name()),ability,before);emit("action.observed",action,"observed",null,null,null);emit("action.resolved",action,"refused",reason,null,null); + public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,String ability,String reason){refused(player,active,before,ability,false,reason);} + public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,String ability,boolean learn,String reason){ + var action=new Action(UUID.randomUUID(),context(player,active,before.name()),ability,learn,before);emit("action.observed",action,"observed",null,null,null);emit("action.resolved",action,"refused",reason,null,null); } public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null);} public void resolved(Action action,AbilityEngine.Mutation mutation){ if(mutation!=null&&mutation.status()==games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutationStatus.APPLIED&&mutation.record()!=null){ emit("action.resolved",action,"accepted","acknowledged",mutation.record(),"acknowledged"); - emit("ability.unlearned",action,"accepted","acknowledged",mutation.record(),"acknowledged"); + if(action.learn()){emit("ability.learned",action,"accepted","acknowledged",mutation.record(),"acknowledged");emit("ability.assigned",action,"accepted","acknowledged",mutation.record(),"acknowledged");} + else emit("ability.unlearned",action,"accepted","acknowledged",mutation.record(),"acknowledged"); }else emit("action.resolved",action,"refused",mutation==null?"unavailable":mutation.status().name().toLowerCase(java.util.Locale.ROOT),mutation==null?null:mutation.record(),null); } private void emit(String type,Action action,String outcome,String reason,CharacterRecord after,String nativePublication){ - var before=action.before();var data=new LinkedHashMap();data.put("action_kind","ability_unlearn");data.put("ability_id",action.ability());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("outcome",outcome); + var before=action.before();var data=new LinkedHashMap();data.put("action_kind",action.learn()?"ability_learn":"ability_unlearn");data.put("ability_id",action.ability());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("outcome",outcome); var beforeCooldown=before.cooldowns().get(action.ability());if(beforeCooldown!=null)data.put("cooldown_before",beforeCooldown.toString()); - if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());var afterCooldown=after.cooldowns().get(action.ability());if(afterCooldown!=null)data.put("cooldown_after",afterCooldown.toString());var cleared=new ArrayList();for(int slot=0;slot=after.loadout().size()||!action.ability().equals(after.loadout().get(slot))))cleared.add(slot);data.put("cleared_slot_indexes",cleared);data.put("slot_index_base",0);} - if(nativePublication!=null){data.put("native_publication",nativePublication);data.put("owned_cleanup","invalidation_acknowledged_close_attempted");} - if(reason!=null)data.put(type.equals("ability.unlearned")?"native_boundary":"reason",reason); + if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());var afterCooldown=after.cooldowns().get(action.ability());if(afterCooldown!=null)data.put("cooldown_after",afterCooldown.toString());if(action.learn()){data.put("ability_slot",after.loadout().indexOf(action.ability()));data.put("slot_index_base",0);}else{var cleared=new ArrayList();for(int slot=0;slot=after.loadout().size()||!action.ability().equals(after.loadout().get(slot))))cleared.add(slot);data.put("cleared_slot_indexes",cleared);data.put("slot_index_base",0);}} + if(nativePublication!=null){data.put("native_publication",nativePublication);if(!action.learn())data.put("owned_cleanup","invalidation_acknowledged_close_attempted");} + if(reason!=null)data.put(type.startsWith("ability.")?"native_boundary":"reason",reason); try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){} } private EventInput.Context context(org.bukkit.entity.Player player,EquipmentSessions.Active active,String characterName){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),characterName,active.session(),location(player.getLocation()));} private EventInput.Location location(Location location){return new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact");} - private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:ability-unlearn:"+action+":"+type).getBytes(StandardCharsets.UTF_8));} + private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:ability-mutation:"+action+":"+type).getBytes(StandardCharsets.UTF_8));} } diff --git a/src/main/java/games/dmg/fantasystick/events/DownedEventBridge.java b/src/main/java/games/dmg/fantasystick/events/DownedEventBridge.java index 3b08646..57a1ab9 100644 --- a/src/main/java/games/dmg/fantasystick/events/DownedEventBridge.java +++ b/src/main/java/games/dmg/fantasystick/events/DownedEventBridge.java @@ -122,8 +122,11 @@ public final class DownedEventBridge { data.put("outcome", "completed"); emit(stable(episode.id(), "published"), episode.outcomeOperation(), episode.resolvedAt(), "action.resolved", data); + if(episode.outcome()==DownedEpisode.Outcome.REVIVED&&episode.protectionUntil()!=null&&episode.decision()!=null&&"revive".equals(episode.decision().source()))protectionApplied(episode); } + private void protectionApplied(DownedEpisode episode){var data=common(episode);data.put("effect_id","revive_downed_invulnerability");data.put("application_id",stable(episode.id(),"revive-protection").toString());data.put("stage","native_application_acknowledged");data.put("outcome","applied");data.put("protection_until",episode.protectionUntil());data.put("end_policy","persisted_absolute_deadline");emit(stable(episode.id(),"protection-applied"),episode.outcomeOperation(),episode.resolvedAt(),"effect.applied",data);} + public void prevented(DownedEpisode episode, String reason, double amount) { if (!Double.isFinite(amount) || amount < 0) return; var now = clock.instant(); diff --git a/src/main/java/games/dmg/fantasystick/events/ReviveEventBridge.java b/src/main/java/games/dmg/fantasystick/events/ReviveEventBridge.java new file mode 100644 index 0000000..6acb73c --- /dev/null +++ b/src/main/java/games/dmg/fantasystick/events/ReviveEventBridge.java @@ -0,0 +1,40 @@ +package games.dmg.fantasystick.events; + +import games.dmg.fantasystick.ability.AbilityExecution; +import games.dmg.fantasystick.persistence.CharacterRecord; +import games.dmg.fantasystick.persistence.RevivalStore; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Consumer; +import org.bukkit.Location; +import org.bukkit.entity.Player; + +/** Learned Revive Tail-branch cast and consume-once restoration evidence. */ +public final class ReviveEventBridge { + public record Cast(UUID actionId,EventInput.Context caster,UUID casterCharacter,String casterCharacterName, + UUID targetPlayer,String targetPlayerName,Map targetLocation, + Instant castAt,Instant cooldownUntil,double range){ + public Cast{Objects.requireNonNull(actionId);Objects.requireNonNull(caster);Objects.requireNonNull(casterCharacter);Objects.requireNonNull(casterCharacterName);Objects.requireNonNull(targetPlayer);Objects.requireNonNull(targetPlayerName);Objects.requireNonNull(targetLocation);Objects.requireNonNull(castAt);Objects.requireNonNull(cooldownUntil);} + } + private final Consumer sink;private final Clock clock; + public ReviveEventBridge(Consumer sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);} + public Cast cast(AbilityExecution execution,Player target){var input=execution.input();var caster=execution.character();var now=clock.instant();var action=new Cast(UUID.randomUUID(),context(input.player(),input.session(),caster.name()),caster.id(),caster.name(),target.getUniqueId(),target.getName(),locationData(target.getLocation(),now),now,caster.cooldowns().get("revive"),input.player().getLocation().distance(target.getLocation()));emitAbility("ability.cast",action,"accepted","cooldown_committed",null);return action;} + public void resolved(Cast cast,String outcome,String stage){emitAbility("ability.resolved",cast,outcome,stage,null);} + public void failed(Cast cast,String reason){emitAbility("ability.resolved",cast,"failed","tail_restoration",reason);} + public void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Player carrier){revived(cast,reservation,dead,restored,carrier,false);} + public void recovered(RevivalStore.Reservation reservation,CharacterRecord restored,Player carrier){if(reservation.administrative())return;revived(null,reservation,null,restored,carrier,true);} + + private void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Player carrier,boolean recovery){var data=new LinkedHashMap();data.put("revival_kind","learned_tail");data.put("selected_branch","tail");data.put("reservation_id",reservation.id().toString());data.put("death_id",reservation.death().toString());data.put("tail_id",reservation.death().toString());data.put("caster_character_id",reservation.casterCharacter().toString());if(cast!=null){data.put("caster_player_id",cast.caster().playerId().toString());data.put("caster_player_name",cast.caster().playerName());data.put("cast_action_id",cast.actionId().toString());}data.put("carrier_player_id",carrier.getUniqueId().toString());data.put("carrier_player_name",carrier.getName());data.put("source_slot",reservation.sourceSlot());data.put("destination_slot",reservation.sourceAmount()==1?reservation.sourceSlot():reservation.destinationSlot());data.put("source_quantity_before",Integer.toString(reservation.sourceAmount()));data.put("source_quantity_after",Integer.toString(reservation.sourceAmount()-1));data.put("consumed_quantity","1");data.put("restored_character_id",restored.id().toString());data.put("restored_character_name",restored.name());data.put("restored_revision",restored.revision().toString());data.put("restored_health",restored.health().toPlainString());data.put("maximum_health",restored.maximumHealth().toPlainString());data.put("no_auto_entry",true);data.put("recovery_reconciliation",recovery);data.put("outcome","acknowledged");data.put("carrier_location",locationData(carrier.getLocation(),clock.instant()));var context=new EventInput.Context(carrier.getUniqueId(),carrier.getName(),null,null,null,location(carrier.getLocation()));emit(id(reservation.id(),"character-revived"),reservation.id(),reservation.createdAt(),"character.revived",context,data);if(cast!=null)emitAbility("ability.resolved",cast,"restored","tail_restoration_acknowledged",null,Map.of("reservation_id",reservation.id().toString(),"death_id",reservation.death().toString(),"restored_character_id",restored.id().toString()));} + private void emitAbility(String type,Cast cast,String outcome,String stage,String reason){emitAbility(type,cast,outcome,stage,reason,Map.of());} + private void emitAbility(String type,Cast cast,String outcome,String stage,String reason,Map extra){var data=new LinkedHashMap();data.put("ability_id","revive");data.put("selected_branch","tail");data.put("stage",stage);data.put("outcome",outcome);data.put("cooldown_until",cast.cooldownUntil());data.put("caster_character_id",cast.casterCharacter().toString());data.put("target_player_id",cast.targetPlayer().toString());data.put("target_player_name",cast.targetPlayerName());data.put("target_location",cast.targetLocation());data.put("final_range",Double.toString(cast.range()));data.put("line_of_sight",true);if(reason!=null)data.put("reason",reason);data.putAll(extra);emit(id(cast.actionId(),type),cast.actionId(),cast.castAt(),type,cast.caster(),data);} + private EventInput.Context context(Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));} + private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");} + private static Map locationData(Location value,Instant at){return Map.of("world_id",value.getWorld().getUID().toString(),"world_name",value.getWorld().getName(),"x",Double.toString(value.getX()),"y",Double.toString(value.getY()),"z",Double.toString(value.getZ()),"captured_at",at,"quality","exact");} + private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:revive:"+action+":"+type).getBytes(StandardCharsets.UTF_8));} + private void emit(UUID id,UUID action,Instant time,String type,EventInput.Context context,Map data){try{sink.accept(new EventInput(id,action,time,type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}} +} diff --git a/src/nativeTest/java/games/dmg/fantasystick/ReviveNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/ReviveNativeTest.java index b916eef..f125bb8 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/ReviveNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/ReviveNativeTest.java @@ -45,10 +45,11 @@ class ReviveNativeTest { assertEquals(BigInteger.ZERO, saved.unspentPoints()); assertEquals(org.bukkit.Material.ECHO_SHARD, actor.core.inventory.getItem(2).getType()); } + var records=events(data);var learned=records.stream().filter(event->event.get("type").getAsString().endsWith("ability.learned")&&event.getAsJsonObject("data").get("ability_id").getAsString().equals("revive")).toList();var assigned=records.stream().filter(event->event.get("type").getAsString().endsWith("ability.assigned")&&event.getAsJsonObject("data").get("ability_id").getAsString().equals("revive")).toList();assertEquals(1,learned.size());assertEquals(1,assigned.size());assertEquals("0",learned.getFirst().getAsJsonObject("data").get("points_after").getAsString());assertEquals(0,assigned.getFirst().getAsJsonObject("data").get("ability_slot").getAsInt());assertEquals(learned.getFirst().getAsJsonObject("data").get("action_id").getAsString(),assigned.getFirst().getAsJsonObject("data").get("action_id").getAsString()); } @Test void learnedAimedReviveRestoresAnyPartyDownedCharacterInPlace()throws Exception{ - try(var fixture=new FantasyStickLifecycleNativeTest.Fixture(data)){fixture.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);withRevive(data,caster);var target=new FantasyStickLifecycleNativeTest.Actor(data);fixture.players.put(caster.core.id,caster.player);fixture.players.put(target.core.id,target.player);var worldId=java.util.UUID.randomUUID();org.mockito.Mockito.when(caster.world.getUID()).thenReturn(worldId);org.mockito.Mockito.when(target.world.getUID()).thenReturn(worldId);fixture.activate(caster);fixture.activate(target);caster.core.location.setWorld(caster.world);caster.core.location.setX(0);caster.core.location.setY(64);caster.core.location.setZ(0);target.core.location.setWorld(caster.world);target.core.location.setX(3);target.core.location.setY(64);target.core.location.setZ(0);var anchor=target.core.location.clone();org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);target.core.health=0;var source=new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));fixture.dispatcher.accept(new org.bukkit.event.entity.PlayerDeathEvent(target.player,source,new java.util.ArrayList<>(),0,net.kyori.adventure.text.Component.empty(),true));var downed=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"downedController");for(int attempt=0;attempt<80&&!downed.downed(target.player);attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}caster.core.inventory.getInventory().setSelectedSlot(2);fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player,target.player,org.bukkit.inventory.EquipmentSlot.HAND));for(int attempt=0;attempt<120&&downed.downed(target.player);attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}for(int attempt=0;attempt<80&&caster.messages.stream().noneMatch(message->message.contains("restored the downed"));attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"sessions");assertFalse(downed.downed(target.player),()->"caster="+caster.messages+", target="+target.messages);assertTrue(sessions.current(sessions.active(target.core.id).orElseThrow()));assertEquals(target.maximumHealth.getValue(),target.player.getHealth());assertEquals(anchor,target.player.getLocation());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,target.core.inventory.getItemInOffHand().getType());var casterRecord=sessions.currentRecord(sessions.active(caster.core.id).orElseThrow()).orElseThrow();assertTrue(casterRecord.cooldowns().get("revive").isAfter(java.time.Instant.now().plusSeconds(86300)));fixture.plugin.setEnabled(false);var records=new java.util.ArrayList();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var types=records.stream().map(event->event.get("type").getAsString()).toList();assertTrue(types.contains("games.dmg.minecraft.fantasystick.ability.cast"),types.toString());assertTrue(types.contains("games.dmg.minecraft.fantasystick.ability.resolved"),types.toString());assertEquals(1,types.stream().filter("games.dmg.minecraft.fantasystick.character.downed_resolved"::equals).count(),types.toString());var resolutionEvent=records.stream().filter(event->event.get("type").getAsString().endsWith("character.downed_resolved")).findFirst().orElseThrow();var resolution=resolutionEvent.getAsJsonObject("data");assertEquals(caster.core.id.toString(),resolutionEvent.get("playerid").getAsString());assertEquals(caster.core.record.id().toString(),resolutionEvent.get("characterid").getAsString());assertEquals(caster.core.record.id().toString(),resolution.get("actor_character_id").getAsString());assertEquals(3.0,resolution.get("final_range").getAsDouble());assertTrue(resolution.get("line_of_sight").getAsBoolean());assertFalse(types.stream().anyMatch(type->type.contains("phoenix")||type.endsWith("character.revived")));} + try(var fixture=new FantasyStickLifecycleNativeTest.Fixture(data)){fixture.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);withRevive(data,caster);var target=new FantasyStickLifecycleNativeTest.Actor(data);fixture.players.put(caster.core.id,caster.player);fixture.players.put(target.core.id,target.player);var worldId=java.util.UUID.randomUUID();org.mockito.Mockito.when(caster.world.getUID()).thenReturn(worldId);org.mockito.Mockito.when(target.world.getUID()).thenReturn(worldId);fixture.activate(caster);fixture.activate(target);caster.core.location.setWorld(caster.world);caster.core.location.setX(0);caster.core.location.setY(64);caster.core.location.setZ(0);target.core.location.setWorld(caster.world);target.core.location.setX(3);target.core.location.setY(64);target.core.location.setZ(0);var anchor=target.core.location.clone();org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);target.core.health=0;var source=new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));fixture.dispatcher.accept(new org.bukkit.event.entity.PlayerDeathEvent(target.player,source,new java.util.ArrayList<>(),0,net.kyori.adventure.text.Component.empty(),true));var downed=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"downedController");for(int attempt=0;attempt<80&&!downed.downed(target.player);attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}caster.core.inventory.getInventory().setSelectedSlot(2);fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player,target.player,org.bukkit.inventory.EquipmentSlot.HAND));for(int attempt=0;attempt<120&&downed.downed(target.player);attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}for(int attempt=0;attempt<80&&caster.messages.stream().noneMatch(message->message.contains("restored the downed"));attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"sessions");assertFalse(downed.downed(target.player),()->"caster="+caster.messages+", target="+target.messages);assertTrue(sessions.current(sessions.active(target.core.id).orElseThrow()));assertEquals(target.maximumHealth.getValue(),target.player.getHealth());assertEquals(anchor,target.player.getLocation());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,target.core.inventory.getItemInOffHand().getType());var casterRecord=sessions.currentRecord(sessions.active(caster.core.id).orElseThrow()).orElseThrow();assertTrue(casterRecord.cooldowns().get("revive").isAfter(java.time.Instant.now().plusSeconds(86300)));fixture.plugin.setEnabled(false);var records=new java.util.ArrayList();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var types=records.stream().map(event->event.get("type").getAsString()).toList();assertTrue(types.contains("games.dmg.minecraft.fantasystick.ability.cast"),types.toString());assertTrue(types.contains("games.dmg.minecraft.fantasystick.ability.resolved"),types.toString());assertEquals(1,types.stream().filter("games.dmg.minecraft.fantasystick.character.downed_resolved"::equals).count(),types.toString());var resolutionEvent=records.stream().filter(event->event.get("type").getAsString().endsWith("character.downed_resolved")).findFirst().orElseThrow();var resolution=resolutionEvent.getAsJsonObject("data");assertEquals(caster.core.id.toString(),resolutionEvent.get("playerid").getAsString());assertEquals(caster.core.record.id().toString(),resolutionEvent.get("characterid").getAsString());assertEquals(caster.core.record.id().toString(),resolution.get("actor_character_id").getAsString());assertEquals(3.0,resolution.get("final_range").getAsDouble());assertTrue(resolution.get("line_of_sight").getAsBoolean());assertFalse(types.stream().anyMatch(type->type.contains("phoenix")||type.endsWith("character.revived")));assertEquals(1,records.stream().filter(event->event.get("type").getAsString().endsWith("effect.applied")).map(event->event.get("id").getAsString()).distinct().count());var applied=records.stream().filter(event->event.get("type").getAsString().endsWith("effect.applied")).findFirst().orElseThrow().getAsJsonObject("data");assertEquals("revive_downed_invulnerability",applied.get("effect_id").getAsString());assertTrue(applied.has("protection_until"));} } @Test void ordinaryTargetSelectsOneTailAndReceivesTheSameLivingSheetOnce() throws Exception { @@ -126,6 +127,7 @@ class ReviveNativeTest { assertTrue(storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive") .isAfter(java.time.Instant.now().plusSeconds(86300))); } + var records=events(data);var revived=records.stream().filter(event->event.get("type").getAsString().endsWith("character.revived")).toList();assertEquals(1,revived.size());var value=revived.getFirst().getAsJsonObject("data");assertEquals("learned_tail",value.get("revival_kind").getAsString());assertEquals(10,value.get("source_slot").getAsInt());assertEquals(1,value.get("destination_slot").getAsInt());assertEquals("2",value.get("source_quantity_before").getAsString());assertEquals("1",value.get("source_quantity_after").getAsString());assertEquals("1",value.get("consumed_quantity").getAsString());assertTrue(value.get("no_auto_entry").getAsBoolean());assertEquals("tail",value.get("selected_branch").getAsString());assertEquals(1,records.stream().filter(event->event.get("type").getAsString().endsWith("ability.cast")&&event.getAsJsonObject("data").has("selected_branch")&&event.getAsJsonObject("data").get("selected_branch").getAsString().equals("tail")).count());assertEquals(1,records.stream().filter(event->event.get("type").getAsString().endsWith("ability.resolved")&&event.getAsJsonObject("data").has("selected_branch")&&event.getAsJsonObject("data").get("selected_branch").getAsString().equals("tail")&&event.getAsJsonObject("data").get("outcome").getAsString().equals("restored")).count()); } @Test void stackedTailWithNoSafeSheetDestinationChangesNothingAndKeepsTheCooldown() throws Exception { @@ -342,6 +344,7 @@ class ReviveNativeTest { 10, 1, -1); try (var store = new games.dmg.fantasystick.persistence.RevivalStore(data)) { store.reserve(reservation).join(); } + var untouchedReservationId=java.util.UUID.randomUUID(); try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) { fixture.players.put(target.core.id, target.player); fixture.plugin.setEnabled(true); var store = (games.dmg.fantasystick.persistence.RevivalStore) @@ -361,7 +364,7 @@ class ReviveNativeTest { secondBase.stats(), secondBase.level(), secondBase.experience(), secondBase.unspentPoints(), java.math.BigDecimal.ZERO, secondBase.learned(), secondBase.loadout(), secondBase.cooldowns(), secondBase.restrictedKills(), true, secondDeath, died); repository.create(second); var tail = new games.dmg.fantasystick.items.TailBooks().create(second); target.core.inventory.setItem(12, tail); - var untouched = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(java.util.UUID.randomUUID(), + var untouched = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(untouchedReservationId, java.util.UUID.randomUUID(), target.core.id, second.id(), secondDeath, java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS), 12, 1, -1); store.reserve(untouched).join(); @@ -370,6 +373,7 @@ class ReviveNativeTest { assertTrue(storage.find(second.id()).get().orElseThrow().dead()); assertEquals(tail, target.core.inventory.getItem(12)); assertTrue(store.find(second.id()).join().isEmpty()); } + var recovered=events(data).stream().filter(event->event.get("type").getAsString().endsWith("character.revived")&&event.getAsJsonObject("data").get("reservation_id").getAsString().equals(reservation.id().toString())).toList();assertFalse(recovered.isEmpty());assertEquals(1,recovered.stream().map(event->event.get("id").getAsString()).distinct().count());assertTrue(recovered.getFirst().getAsJsonObject("data").get("recovery_reconciliation").getAsBoolean());assertEquals(0,events(data).stream().filter(event->event.get("type").getAsString().endsWith("character.revived")&&event.getAsJsonObject("data").get("reservation_id").getAsString().equals(untouchedReservationId.toString())).count()); } private static net.minecraft.network.chat.ClickEvent.Custom abilityButton( @@ -395,6 +399,8 @@ class ReviveNativeTest { return null; } + private static java.util.List events(Path data)throws Exception{var records=new java.util.ArrayList();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}return records;} + private static void withRevive(Path data, FantasyStickLifecycleNativeTest.Actor actor) throws Exception { var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data); var old = repository.load(actor.core.record.id()).orElseThrow(); diff --git a/src/test/java/games/dmg/fantasystick/events/DownedEventBridgeTest.java b/src/test/java/games/dmg/fantasystick/events/DownedEventBridgeTest.java index fe4a3ff..26f4043 100644 --- a/src/test/java/games/dmg/fantasystick/events/DownedEventBridgeTest.java +++ b/src/test/java/games/dmg/fantasystick/events/DownedEventBridgeTest.java @@ -30,7 +30,7 @@ final class DownedEventBridgeTest { events.reconcile(List.of(pending, resolved)); assertEquals(List.of("character.downed", "character.downed", "ability.cast", - "character.downed_resolved", "ability.resolved", "action.resolved", "effect.ended"), + "character.downed_resolved", "ability.resolved", "action.resolved", "effect.applied", "effect.ended"), first.stream().map(EventInput::type).toList()); assertEquals(first.stream().map(EventInput::id).toList(), captured.subList(first.size(), captured.size()).stream().map(EventInput::id).toList()); @@ -56,7 +56,10 @@ final class DownedEventBridgeTest { assertEquals(true, resolution.data().get("line_of_sight")); assertEquals("decision_won", first.get(4).data().get("outcome")); assertEquals("completed", first.get(5).data().get("outcome")); - assertEquals(resolved.protectionUntil(), first.get(6).time()); + assertEquals("revive_downed_invulnerability",first.get(6).data().get("effect_id")); + assertEquals(resolved.protectionUntil(),first.get(6).data().get("protection_until")); + assertEquals("applied",first.get(6).data().get("outcome")); + assertEquals(resolved.protectionUntil(), first.get(7).time()); assertFalse(first.stream().anyMatch(event -> event.type().contains("phoenix"))); }