Compare commits

...
3 Commits
Author SHA1 Message Date
dmg 25eb6e3a6b feat(events): record passive recovery outcomes
Release / release (push) Successful in 13m53s
2026-09-24 22:26:45 -04:00
dmg 51b055da12 feat(events): record exact ability refunds
Release / release (push) Successful in 13m52s
2026-09-24 22:11:35 -04:00
dmg 60ccb34ab6 feat(events): record permanent stat mutations
Release / release (push) Successful in 14m32s
2026-09-24 22:00:02 -04:00
12 changed files with 322 additions and 65 deletions

No files matched your search

@@ -167,6 +167,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.RareEventBridge rareEvents;
private games.dmg.fantasystick.events.BookEventBridge bookEvents;
private games.dmg.fantasystick.events.ProgressionEventBridge progressionEvents;
private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents;
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
@@ -705,13 +709,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
downedEvents = new games.dmg.fantasystick.events.DownedEventBridge(eventLog::emit, Clock.systemUTC());
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC());rareShop.attachEvents(rareEvents);
bookEvents = new games.dmg.fantasystick.events.BookEventBridge(eventLog::emit, Clock.systemUTC());books.attachEvents(bookEvents);
progressionEvents = new games.dmg.fantasystick.events.ProgressionEventBridge(eventLog::emit, Clock.systemUTC());books.attachProgressionEvents(progressionEvents);
abilityEvents = new games.dmg.fantasystick.events.AbilityEventBridge(eventLog::emit, Clock.systemUTC());books.attachAbilityEvents(abilityEvents);
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);
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;
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -814,7 +822,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;
var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
var closingPartyCombat = partyCombat; partyCombat = null;
@@ -38,6 +38,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private final Map<UUID, games.dmg.fantasystick.events.BookEventBridge.Request> pendingBookEvents = new ConcurrentHashMap<>();
private games.dmg.fantasystick.events.BookEventBridge bookEvents;
private games.dmg.fantasystick.events.ProgressionEventBridge progressionEvents;
private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents;
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
private final Map<UUID, Object> adminPending = new ConcurrentHashMap<>();
private final Map<UUID, AdminView> adminViews = new ConcurrentHashMap<>();
private final Map<UUID, SavedView> savedViews = new ConcurrentHashMap<>();
@@ -63,9 +66,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
games.dmg.fantasystick.session.EquipmentSessions.Active active,
int previous, int next) { }
private record SavedView(String nonce, UUID administrator, UUID character, int previous, int next) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision, CharacterRecord before) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids, CharacterRecord before) { }
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids, CharacterRecord before) { }
private record RareView(String nonce,ControlInput input,java.util.Map<String,java.math.BigInteger> quantities){}
private record ReskillView(String nonce,ControlInput input,games.dmg.fantasystick.shop.RareReskillService.Preview preview){}
private final Map<UUID, View> views = new ConcurrentHashMap<>();
@@ -120,6 +123,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void attachRareInventory(games.dmg.fantasystick.shop.RareInventoryDirectory inventory,games.dmg.fantasystick.shop.RarePotionService potions,games.dmg.fantasystick.shop.RareReskillService reskills,games.dmg.fantasystick.shop.RareMagicCoreService magicCores){if(closed||rareInventory!=null)throw new IllegalStateException("Rare inventory already attached or books closed");rareInventory=Objects.requireNonNull(inventory);rarePotions=Objects.requireNonNull(potions);rareReskills=Objects.requireNonNull(reskills);rareMagicCores=Objects.requireNonNull(magicCores);}
public void attachEvents(games.dmg.fantasystick.events.BookEventBridge events){if(closed||bookEvents!=null)throw new IllegalStateException("Book events already attached or controller closed");bookEvents=Objects.requireNonNull(events);}
public void attachProgressionEvents(games.dmg.fantasystick.events.ProgressionEventBridge events){if(closed||progressionEvents!=null)throw new IllegalStateException("Progression events already attached or controller closed");progressionEvents=Objects.requireNonNull(events);}
public void attachAbilityEvents(games.dmg.fantasystick.events.AbilityEventBridge events){if(closed||abilityEvents!=null)throw new IllegalStateException("Ability events already attached or controller closed");abilityEvents=Objects.requireNonNull(events);}
public void attachPassiveEvents(games.dmg.fantasystick.events.PassiveEventBridge events){if(closed||passiveEvents!=null)throw new IllegalStateException("Passive events already attached or controller closed");passiveEvents=Objects.requireNonNull(events);}
@Override public void accept(ControlInput input) { load(input, 0, true); }
@@ -312,7 +318,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
meta.setTitle("Character");
meta.setAuthor("Fantasy Stick");
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision()) : null;
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision(), record) : null;
var allOverview = overviewPages(record, subject, statView, savedState);
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
@@ -335,11 +341,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if ((!definitions.isEmpty() || !passiveDefinitions.isEmpty()) && end == allOverview.size()) {
var abilityView = interactive ? new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
.collect(java.util.stream.Collectors.toUnmodifiableSet()), record) : null;
if (interactive && !definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
var passiveView = interactive ? new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
.collect(java.util.stream.Collectors.toUnmodifiableSet()), record) : null;
if (interactive && !passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id,
@@ -789,49 +795,53 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void statAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !STATS.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var player = connection.getPlayer();
var player = connection.getPlayer();if(event.getTag()==null)return;String raw=event.getTag().string();if(raw.length()>80)return;var match=STAT_PAYLOAD.matcher(raw);if(!match.matches())return;var stat=Stat.valueOf(match.group(2));
var view = statViews.get(player.getUniqueId());
if (view == null || view.input().player() != player || event.getTag() == null) return;
String raw = event.getTag().string();
if (raw.length() > 80) return;
var match = STAT_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
var stat = Stat.valueOf(match.group(2));
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1))
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) { refuseStat(player,stat,"stale_forged_or_repeated_action"); return; }
var currentHealth = java.math.BigDecimal.valueOf(player.getHealth());
var nativeLimit = NativeHealthLimit.read();
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
.whenComplete((purchase, failure) -> {
try {
nextTick.accept(() -> finishPurchase(view, stat, purchase, failure));
} catch (RuntimeException rejected) { report(view.input(), rejected); }
});
var nativeMaximum=java.math.BigDecimal.valueOf(java.util.Objects.requireNonNull(player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue());
var audit=progressionEvents==null?null:progressionEvents.begin(view.input(),view.before(),stat,currentHealth,nativeMaximum,nativeLimit);
try {
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
.whenComplete((purchase, failure) -> {
try { nextTick.accept(() -> finishPurchase(view, stat, audit, purchase, failure)); }
catch (RuntimeException rejected) { if(audit!=null){if(purchase!=null&&purchase.status()==CharacterStorage.StatPurchaseStatus.APPLIED)progressionEvents.increased(audit,purchase.record().orElseThrow(),"scheduler_rejected_before_native_publication",purchase.record().orElseThrow().health());else progressionEvents.failed(audit,"scheduler_rejected");}report(view.input(), rejected); }
});
} catch (RuntimeException failure) { if(audit!=null)progressionEvents.failed(audit,"purchase_rejected");report(view.input(),failure); }
}
private void finishPurchase(StatView view, Stat stat, CharacterStorage.StatPurchase purchase, Throwable failure) {
private void refuseStat(org.bukkit.entity.Player player,Stat stat,String reason){if(progressionEvents==null)return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)progressionEvents.refused(player,active,current,stat,NativeHealthLimit.read(),reason);}
private void finishPurchase(StatView view, Stat stat, games.dmg.fantasystick.events.ProgressionEventBridge.Action audit, CharacterStorage.StatPurchase purchase, Throwable failure) {
var input = view.input();
if (closed || !input.stillCurrent()) return;
if (failure != null) {
report(input, failure);
input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
if(audit!=null)progressionEvents.failed(audit,"save_failed");report(input, failure);
if(!closed&&input.player().isOnline())input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
return;
}
if (purchase.status() == CharacterStorage.StatPurchaseStatus.APPLIED) {
var saved = purchase.record().orElseThrow();
if(closed||!input.stillCurrent()){if(audit!=null)progressionEvents.increased(audit,saved,closed?"plugin_shutdown_before_native_publication":"session_changed_before_native_publication",saved.health());return;}
try {
if (!sessions.acknowledgeProgress(input.session(), view.revision(), saved)) {
if(audit!=null)progressionEvents.increased(audit,saved,"session_changed_before_native_publication",saved.health());
input.player().sendMessage("Stat purchase was saved; reopen the character after the session transition.");
return;
}
} catch (RuntimeException nativeFailure) {
report(input, nativeFailure);
if(audit!=null)progressionEvents.increased(audit,saved,"native_publication_failed",saved.health());report(input, nativeFailure);
input.player().sendMessage("Stat purchase was saved, but native health refresh failed; exit for recovery.");
return;
}
if(audit!=null)progressionEvents.increased(audit,saved,"acknowledged",java.math.BigDecimal.valueOf(input.player().getHealth()));
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
load(input, 0, false);
return;
}
if(audit!=null)progressionEvents.refused(audit,purchase.status().name().toLowerCase(java.util.Locale.ROOT),purchase.record().orElse(null));
if(closed||!input.stillCurrent())return;
String message = switch (purchase.status()) {
case STALE -> "That character page is stale; reopen it before spending points.";
case NO_POINTS -> "This character has no unspent points.";
@@ -849,18 +859,15 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (closed || abilityEngine == null || !unlock && !unlearn
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player = connection.getPlayer();
var player = connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String ability=match.group(2);
var view = abilityViews.get(player.getUniqueId());
if (view == null || view.input().player() != player) return;
String raw = event.getTag().string();
if (raw.length() > 120) return;
var match = ABILITY_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
String ability = match.group(2);
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;
var operation = unlock ? abilityEngine.purchase(view.input(), ability, view.revision())
: abilityEngine.unlearn(view.input(), ability, view.revision());
operation.whenComplete((mutation, failure) -> {
if(audit!=null){if(failure!=null)abilityEvents.failed(audit,"save_or_native_cleanup_uncertain");else abilityEvents.resolved(audit,mutation);}
if (closed || !view.input().stillCurrent()) return;
if (failure != null) {
report(view.input(), failure);
@@ -872,6 +879,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, 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);}
@org.bukkit.event.EventHandler
public void passiveAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
boolean unlock = PASSIVE_UNLOCK.equals(event.getIdentifier());
@@ -879,16 +888,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (closed || passiveEngine == null || !unlock && !unlearn
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player = connection.getPlayer(); var view = passiveViews.get(player.getUniqueId());
if (view == null || view.input().player() != player) return;
String raw = event.getTag().string(); if (raw.length() > 120) return;
var match = ABILITY_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
String passive = match.group(2);
var player=connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String passive=match.group(2);var definition=passives.find(passive).orElse(null);if(definition==null)return;var view=passiveViews.get(player.getUniqueId());boolean owned=games.dmg.fantasystick.passive.QuickerRegenerationI.ID.equals(passive);
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(passive)
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(owned)refusePassive(player,definition,unlock,"stale_forged_or_repeated_action");return;}
var audit=owned&&passiveEvents!=null?passiveEvents.begin(view.input(),view.before(),definition,unlock):null;
var operation = unlock ? passiveEngine.purchase(view.input(), passive, view.revision())
: passiveEngine.unlearn(view.input(), passive, view.revision());
operation.whenComplete((mutation, failure) -> {
if(audit!=null){if(failure!=null)passiveEvents.failed(audit,"save_or_native_publication_uncertain");else passiveEvents.resolved(audit,mutation);}
if (closed || !view.input().stillCurrent()) return;
if (failure != null) {
report(view.input(), failure);
@@ -900,6 +907,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
});
}
private void refusePassive(org.bukkit.entity.Player player,games.dmg.fantasystick.passive.PassiveDefinition definition,boolean learn,String reason){if(passiveEvents==null)return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)passiveEvents.refused(player,active,current,definition,learn,reason);}
private static String passiveMessage(boolean unlock, String passive,
CharacterStorage.PassiveMutationStatus status) {
return switch (status) {
@@ -0,0 +1,51 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.ability.AbilityEngine;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Feature-owned exact ability-refund outcomes; never an authorization or refund authority. */
public final class AbilityEventBridge {
public record Action(UUID actionId,EventInput.Context context,String ability,CharacterRecord before){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(ability);Objects.requireNonNull(before);}
}
private final Consumer<EventInput> sink;private final Clock clock;
public AbilityEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(ControlInput input,CharacterRecord before,String ability){
var action=new Action(UUID.randomUUID(),context(input.player(),input.session(),before.name()),ability,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 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");
}else emit("action.resolved",action,"refused",mutation==null?"unavailable":mutation.status().name().toLowerCase(java.util.Locale.ROOT),mutation==null?null:mutation.record(),null);
}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after,String nativePublication){
var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind","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<Integer>();for(int slot=0;slot<before.loadout().size();slot++)if(action.ability().equals(before.loadout().get(slot))&&(slot>=after.loadout().size()||!action.ability().equals(after.loadout().get(slot))))cleared.add(slot);data.put("cleared_slot_indexes",cleared);data.put("slot_index_base",0);}
if(nativePublication!=null){data.put("native_publication",nativePublication);data.put("owned_cleanup","invalidation_acknowledged_close_attempted");}
if(reason!=null)data.put(type.equals("ability.unlearned")?"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));}
}
@@ -0,0 +1,39 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.passive.PassiveDefinition;
import games.dmg.fantasystick.passive.PassiveEngine;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Feature-owned acknowledged passive mutations and Quicker Regeneration configuration records. */
public final class PassiveEventBridge {
public record Action(UUID actionId,EventInput.Context context,String passive,boolean learn,
PassiveDefinition definition,CharacterRecord before){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(passive);Objects.requireNonNull(definition);Objects.requireNonNull(before);}
}
private final Consumer<EventInput> sink;private final Clock clock;
public PassiveEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(ControlInput input,CharacterRecord before,PassiveDefinition definition,boolean learn){var action=new Action(UUID.randomUUID(),context(input.player(),input.session(),before.name()),definition.id(),learn,definition,before);emit("action.observed",action,"observed",null,null);return action;}
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,PassiveDefinition definition,boolean learn,String reason){var action=new Action(UUID.randomUUID(),context(player,active,before.name()),definition.id(),learn,definition,before);emit("action.observed",action,"observed",null,null);emit("action.resolved",action,"refused",reason,null);}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null);}
public void resolved(Action action,PassiveEngine.Mutation mutation){if(mutation!=null&&mutation.status()==games.dmg.fantasystick.persistence.CharacterStorage.PassiveMutationStatus.APPLIED&&mutation.record()!=null){emit("action.resolved",action,"accepted","acknowledged",mutation.record());emit(action.learn()?"passive.learned":"passive.unlearned",action,"accepted","acknowledged",mutation.record());}else emit("action.resolved",action,"refused",mutation==null?"unavailable":mutation.status().name().toLowerCase(java.util.Locale.ROOT),mutation==null?null:mutation.record());}
public void configRead(String actorKind,UUID player,String playerName,String key,String value){config("config.read",actorKind,player,playerName,key,null,value);}
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after){config("config.changed",actorKind,player,playerName,key,before,after);}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after){var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"passive_learn":"passive_unlearn");data.put("passive_id",action.passive());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("point_cost",action.definition().pointCost().toString());var requirements=new LinkedHashMap<String,Object>();action.definition().requirements().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->requirements.put(entry.getKey().name(),entry.getValue().toString()));data.put("stat_prerequisites",requirements);data.put("passive_prerequisites",action.definition().prerequisites().stream().sorted().toList());data.put("outcome",outcome);if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());int slot=(action.learn()?after.passives():before.passives()).indexOf(action.passive());data.put("passive_slot",slot);data.put("slot_index_base",0);data.put("native_publication","acknowledged");data.put("owned_cleanup",action.learn()?"passive_state_acknowledged":"passive_state_acknowledged");}if(reason!=null)data.put(type.startsWith("passive.")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(playerName!=null)data.put("actor_player_name",playerName);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application","future_recovery_pulses");data.put("outcome","acknowledged");var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(org.bukkit.entity.Player player,EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:passive:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,77 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Feature-owned permanent-stat outcomes, isolated from purchase and native-health authority. */
public final class ProgressionEventBridge {
public record Action(UUID actionId, EventInput.Context context, Stat stat, BigInteger expectedRevision,
BigInteger statBefore, BigInteger pointsBefore, BigDecimal healthBefore,
BigDecimal maximumBefore, BigDecimal nativeLimit) {
public Action {
Objects.requireNonNull(actionId); Objects.requireNonNull(context); Objects.requireNonNull(stat);
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(statBefore); Objects.requireNonNull(pointsBefore);
Objects.requireNonNull(healthBefore); Objects.requireNonNull(maximumBefore); Objects.requireNonNull(nativeLimit);
}
}
private final Consumer<EventInput> sink;
private final Clock clock;
public ProgressionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);
}
public Action begin(ControlInput input, CharacterRecord before, Stat stat, BigDecimal health,
BigDecimal maximum, BigDecimal nativeLimit) {
Objects.requireNonNull(input);Objects.requireNonNull(before);Objects.requireNonNull(stat);
var action=new Action(UUID.randomUUID(),context(input,before.name()),stat,before.revision(),
before.stats().get(stat),before.unspentPoints(),health,maximum.min(nativeLimit),nativeLimit);
emit("action.observed",action,"observed",null,null,null,null);
return action;
}
public void refused(Action action,String reason,CharacterRecord current){
emit("action.resolved",action,"refused",reason,current,null,null);
}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null,null);}
public void increased(Action action,CharacterRecord saved,String nativePublication,BigDecimal nativeHealthAfter){
emit("action.resolved",action,"accepted","acknowledged",saved,nativePublication,nativeHealthAfter);
emit("character.stat_increased",action,"accepted","acknowledged",saved,nativePublication,nativeHealthAfter);
}
public void refused(org.bukkit.entity.Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,
CharacterRecord current,Stat stat,BigDecimal nativeLimit,String reason){
var input=new ControlInput(player,active,0,"",ControlInput.Kind.BOOK,ControlInput.Gesture.USE,null,0,0,()->true);
var action=begin(input,current,stat,BigDecimal.valueOf(player.getHealth()),current.maximumHealth().min(nativeLimit),nativeLimit);
emit("action.resolved",action,"refused",reason,current,null,null);
}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after,
String nativePublication,BigDecimal nativeHealthAfter){
var data=new LinkedHashMap<String,Object>();data.put("action_kind","character_stat_increase");
data.put("stat_id",action.stat().name());data.put("expected_revision",action.expectedRevision().toString());
data.put("before_revision",action.expectedRevision().toString());data.put("stat_before",action.statBefore().toString());
data.put("points_before",action.pointsBefore().toString());data.put("health_before",action.healthBefore().toPlainString());
data.put("maximum_health_before",action.maximumBefore().toPlainString());data.put("native_health_ceiling",action.nativeLimit().toPlainString());
data.put("outcome",outcome);if(reason!=null)data.put(type.equals("character.stat_increased")?"native_boundary":"reason",reason);
if(after!=null){data.put("after_revision",after.revision().toString());data.put("stat_after",after.stats().get(action.stat()).toString());data.put("points_after",after.unspentPoints().toString());data.put("health_after",(nativeHealthAfter==null?after.health():nativeHealthAfter).toPlainString());data.put("maximum_health_after",after.maximumHealth().min(action.nativeLimit()).toPlainString());}
if(nativePublication!=null)data.put("native_publication",nativePublication);
try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(ControlInput input,String characterName){var player=input.player();return new EventInput.Context(player.getUniqueId(),player.getName(),input.session().character(),characterName,input.session().session(),location(player.getLocation()));}
private EventInput.Location location(Location location){return new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:progression:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,35 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** One authoritative out-of-combat healing publication with bounded passive attribution. */
public final class RecoveryEventBridge {
public record Action(UUID actionId,EventInput.Context context,BigInteger expectedRevision,
BigDecimal healthBefore,BigDecimal proposed,BigInteger configuredBase,
BigInteger configuredQuickerRegeneration){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(healthBefore);Objects.requireNonNull(proposed);Objects.requireNonNull(configuredBase);Objects.requireNonNull(configuredQuickerRegeneration);}
}
private final Consumer<EventInput> sink;private final Clock clock;
public RecoveryEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(Player player,EquipmentSessions.Active active,CharacterRecord record,double current,double proposed,BigInteger base,BigInteger bonus){var action=new Action(UUID.randomUUID(),context(player,active,record.name()),record.revision(),BigDecimal.valueOf(current),BigDecimal.valueOf(proposed),base,bonus);emit("action.observed",action,"observed",null,null,null,null,null);return action;}
public void refused(Action action,String reason){emit("action.resolved",action,"refused",reason,null,null,null,null);}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null,null,null);}
public void healed(Action action,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){emit("action.resolved",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);emit("combat.healed",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);}
private void emit(String type,Action action,String outcome,String reason,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){var data=new LinkedHashMap<String,Object>();data.put("action_kind","out_of_combat_recovery");data.put("recovery_kind","out_of_combat");data.put("cause","recovery_timer");data.put("expected_revision",action.expectedRevision().toString());data.put("health_before",action.healthBefore().toPlainString());data.put("proposed_total",action.proposed().toPlainString());data.put("configured_base",action.configuredBase().toString());data.put("configured_quicker_regeneration_i",action.configuredQuickerRegeneration().toString());data.put("outcome",outcome);if(healthAfter!=null)data.put("health_after",healthAfter.toPlainString());if(acceptedTotal!=null){data.put("accepted_total",acceptedTotal.toPlainString());data.put("base_contribution",baseContribution.toPlainString());if(passiveContribution.signum()>0)data.put("passive_contributions",Map.of("quicker_regeneration_i",passiveContribution.toPlainString()));data.put("native_publication","acknowledged");}if(reason!=null)data.put(type.equals("combat.healed")?"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(Player player,EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:recovery:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -84,6 +84,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private OutOfCombatRecovery outOfCombatRecovery;
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
private games.dmg.fantasystick.passive.QuickerRegenerationI quickerRegeneration;
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
private games.dmg.fantasystick.persistence.QuickerRegenerationIISettings quickerRegenerationIISettings;
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
@@ -353,6 +354,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
outOfCombatRecovery = Objects.requireNonNull(recovery);
}
public void attachPassiveEvents(games.dmg.fantasystick.events.PassiveEventBridge events){if(closed||passiveEvents!=null)throw new IllegalStateException("Passive events already attached or command closed");passiveEvents=Objects.requireNonNull(events);}
public void attachQuickerRegeneration(games.dmg.fantasystick.persistence.QuickerRegenerationSettings settings,
games.dmg.fantasystick.passive.QuickerRegenerationI passive,
games.dmg.fantasystick.passive.PassiveRegistry registry) {
@@ -1096,9 +1099,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private void quickerRegenerationConfiguration(CommandSender sender, String[] args) {
String key=games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY;String before=quickerRegenerationSettings.value();String actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;
if (args.length == 2) {
sender.sendMessage(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY + "="
+ quickerRegenerationSettings.value() + " (applies to future recovery pulses only)"); return;
if(passiveEvents!=null)passiveEvents.configRead(actorKind,actorPlayer,actorName,key,before);
sender.sendMessage(key + "=" + before + " (applies to future recovery pulses only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickerRegenerationSettings.Update> operation;
try { operation = quickerRegenerationSettings.set(args[2]); }
@@ -1111,7 +1115,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return; boolean applied = false;
if (failure == null) {
applied = quickerRegenerationSettings.publish(saved);
if (applied) { quickerRegeneration.configure(saved.settings()); passiveRegistry.replace(quickerRegeneration.definition()); }
if (applied) { quickerRegeneration.configure(saved.settings()); passiveRegistry.replace(quickerRegeneration.definition());if(passiveEvents!=null)passiveEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value()); }
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -66,6 +66,8 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
private final CombatFeedback feedback;
private final CombatSources sources;
private java.util.function.Function<CharacterRecord, BigInteger> bonusHealing = ignored -> BigInteger.ZERO;
private java.util.function.Function<CharacterRecord, BigInteger> quickerRegenerationContribution = ignored -> BigInteger.ZERO;
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
private final Map<UUID, State> states = new HashMap<>();
private final ArrayDeque<UUID> order = new ArrayDeque<>();
private final Set<UUID> queued = new HashSet<>();
@@ -96,6 +98,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
if (closed || task != null || !states.isEmpty()) throw new IllegalStateException("Recovery evaluation already started");
bonusHealing = Objects.requireNonNull(bonus);
}
public synchronized void attachEvents(games.dmg.fantasystick.events.RecoveryEventBridge events,java.util.function.Function<CharacterRecord,BigInteger> quickerContribution){if(closed||recoveryEvents!=null)throw new IllegalStateException("Recovery events already attached or recovery closed");recoveryEvents=Objects.requireNonNull(events);quickerRegenerationContribution=Objects.requireNonNull(quickerContribution);}
public synchronized void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin);
@@ -177,18 +180,19 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
state.lastAttempt = now; // Full-health checks are not banked for later replay.
return;
}
BigInteger exactAmount;
try { exactAmount = configured.amount().add(Objects.requireNonNull(bonusHealing.apply(record))); }
BigInteger exactAmount;BigInteger quickerContribution;
try { exactAmount = configured.amount().add(Objects.requireNonNull(bonusHealing.apply(record)));quickerContribution=Objects.requireNonNull(quickerRegenerationContribution.apply(record)); }
catch (RuntimeException failure) { state.lastAttempt = now; report(record.id(), failure); return; }
if (exactAmount.signum() < 0) { state.lastAttempt = now; report(record.id(), new IllegalStateException("Negative recovery bonus")); return; }
double amount = exactAmount.bitLength() > 1023 ? maximum - current : exactAmount.doubleValue();
double proposed = Math.min(maximum - current, amount);
if (!Double.isFinite(proposed) || proposed <= 0) return;
var audit=recoveryEvents==null?null:recoveryEvents.begin(player,state.active,record,current,proposed,configured.amount(),quickerContribution);
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
try{healingEvents.accept(event);}catch(RuntimeException failure){if(audit!=null)recoveryEvents.failed(audit,"native_healing_event_failed");throw failure;}
double accepted = Math.min(proposed, Math.min(maximum - current, event.getAmount()));
state.lastAttempt = now;
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {if(audit!=null)recoveryEvents.refused(audit,event.isCancelled()?"native_event_cancelled":"no_positive_healing_accepted");return;}
Object token = new Object();
state.pending = token;
long generation = state.generation;
@@ -196,44 +200,45 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
BigDecimal target = BigDecimal.valueOf(current + accepted);
BigDecimal limit;
try { limit = nativeLimit.get(); }
catch (RuntimeException failure) { state.pending = null; state.lastAttempt = clock.instant(); report(record.id(), failure); return; }
catch (RuntimeException failure) { state.pending = null; state.lastAttempt = clock.instant();if(audit!=null)recoveryEvents.failed(audit,"native_health_limit_unavailable"); report(record.id(), failure); return; }
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> complete(state, token, generation, expected, current, saved, failure));
nextTick.accept(() -> complete(state, token, generation, expected, current, configured.amount(), quickerContribution, audit, saved, failure));
} catch (RuntimeException rejected) {
synchronized (OutOfCombatRecovery.this) {
if (state.pending == token) { state.pending = null; state.lastAttempt = clock.instant(); }
}
report(record.id(), rejected);
if(audit!=null)recoveryEvents.failed(audit,"scheduler_rejected_after_save_uncertain");report(record.id(), rejected);
}
});
}
private synchronized void complete(State state, Object token, long generation, BigInteger expected,
double nativeBefore, java.util.Optional<CharacterRecord> saved, Throwable failure) {
double nativeBefore, BigInteger configuredBase,BigInteger quickerConfigured,games.dmg.fantasystick.events.RecoveryEventBridge.Action audit,java.util.Optional<CharacterRecord> saved, Throwable failure) {
if (state.pending != token) return;
state.pending = null;
if (failure != null || saved == null || saved.isEmpty()) {
state.lastAttempt = clock.instant();
report(state.active.character(), failure != null ? failure : new IllegalStateException("Character unavailable for recovery save"));
if(audit!=null)recoveryEvents.failed(audit,failure!=null?"health_save_failed":"character_unavailable");report(state.active.character(), failure != null ? failure : new IllegalStateException("Character unavailable for recovery save"));
return;
}
var persisted = saved.orElseThrow();
boolean acknowledged = sessions.acknowledgeProgress(state.active, expected, persisted);
if (!acknowledged) {
report(state.active.character(), new IllegalStateException("Saved recovery could not synchronize with its active session"));
if(audit!=null)recoveryEvents.failed(audit,"saved_but_session_publication_refused");report(state.active.character(), new IllegalStateException("Saved recovery could not synchronize with its active session"));
return;
}
if (closed || generation != state.generation || states.get(state.active.player()) != state
|| !sessions.current(state.active)) return;
|| !sessions.current(state.active)) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_session_changed_before_native_heal");return;}
var player = sessions.currentPlayer(state.active).orElse(null);
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) return;
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_native_health_changed");return;}
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
if (attribute == null) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_maximum_health_unavailable");return;}
double maximum = Math.min(attribute.getValue(), persisted.maximumHealth().doubleValue());
double publish = Math.min(maximum, persisted.health().doubleValue());
if (!Double.isFinite(maximum) || !Double.isFinite(publish) || publish <= nativeBefore) return;
player.setHealth(publish);
if (!Double.isFinite(maximum) || !Double.isFinite(publish) || publish <= nativeBefore) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_no_native_heal_available");return;}
try{player.setHealth(publish);}catch(RuntimeException nativeFailure){if(audit!=null)recoveryEvents.failed(audit,"native_health_publication_failed");throw nativeFailure;}
if(audit!=null){var total=BigDecimal.valueOf(publish-nativeBefore);var base=total.min(new BigDecimal(configuredBase));var passive=total.subtract(base).min(new BigDecimal(quickerConfigured)).max(BigDecimal.ZERO);recoveryEvents.healed(audit,BigDecimal.valueOf(publish),total,base,passive);}
if (feedback != null) feedback.report("Out-of-Combat Recovery", player, AbilityReport.Type.HEALING, player,
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
}
@@ -22,7 +22,7 @@ class CharacterBookNativeTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookEventSinkFailureCannotChangeTheAlreadyAuthorizedNativeBookPath() {
var player=org.mockito.Mockito.mock(org.bukkit.entity.Player.class);var world=org.mockito.Mockito.mock(org.bukkit.World.class);var record=org.mockito.Mockito.mock(games.dmg.fantasystick.persistence.CharacterRecord.class);var playerId=UUID.randomUUID();var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(playerId,UUID.randomUUID(),UUID.randomUUID());org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId);org.mockito.Mockito.when(player.getName()).thenReturn("Player");org.mockito.Mockito.when(world.getUID()).thenReturn(UUID.randomUUID());org.mockito.Mockito.when(world.getName()).thenReturn("world");org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world,1,2,3));org.mockito.Mockito.when(record.name()).thenReturn("Hero");org.mockito.Mockito.when(record.revision()).thenReturn(java.math.BigInteger.ONE);var input=new games.dmg.fantasystick.session.ControlInput(player,active,0,"",games.dmg.fantasystick.session.ControlInput.Kind.BOOK,games.dmg.fantasystick.session.ControlInput.Gesture.USE,null,0,0,()->true);var bridge=new games.dmg.fantasystick.events.BookEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());assertDoesNotThrow(()->{var request=bridge.request(input,record,0);bridge.opened(request,record,3,1);bridge.refused(request,"retry");});
var player=org.mockito.Mockito.mock(org.bukkit.entity.Player.class);var world=org.mockito.Mockito.mock(org.bukkit.World.class);var record=org.mockito.Mockito.mock(games.dmg.fantasystick.persistence.CharacterRecord.class);var playerId=UUID.randomUUID();var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(playerId,UUID.randomUUID(),UUID.randomUUID());org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId);org.mockito.Mockito.when(player.getName()).thenReturn("Player");org.mockito.Mockito.when(world.getUID()).thenReturn(UUID.randomUUID());org.mockito.Mockito.when(world.getName()).thenReturn("world");org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world,1,2,3));org.mockito.Mockito.when(record.name()).thenReturn("Hero");org.mockito.Mockito.when(record.revision()).thenReturn(java.math.BigInteger.ONE);org.mockito.Mockito.when(record.stats()).thenReturn(java.util.Map.of(games.dmg.fantasystick.progression.Stat.CON,java.math.BigInteger.ZERO));org.mockito.Mockito.when(record.unspentPoints()).thenReturn(java.math.BigInteger.ONE);org.mockito.Mockito.when(record.maximumHealth()).thenReturn(java.math.BigDecimal.TEN);org.mockito.Mockito.when(record.health()).thenReturn(java.math.BigDecimal.ONE);org.mockito.Mockito.when(record.cooldowns()).thenReturn(java.util.Map.of());org.mockito.Mockito.when(record.loadout()).thenReturn(java.util.List.of("","","","","","",""));var input=new games.dmg.fantasystick.session.ControlInput(player,active,0,"",games.dmg.fantasystick.session.ControlInput.Kind.BOOK,games.dmg.fantasystick.session.ControlInput.Gesture.USE,null,0,0,()->true);var bridge=new games.dmg.fantasystick.events.BookEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());var progression=new games.dmg.fantasystick.events.ProgressionEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());var abilityEvents=new games.dmg.fantasystick.events.AbilityEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());var passiveEvents=new games.dmg.fantasystick.events.PassiveEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());assertDoesNotThrow(()->{var request=bridge.request(input,record,0);bridge.opened(request,record,3,1);bridge.refused(request,"retry");var action=progression.begin(input,record,games.dmg.fantasystick.progression.Stat.CON,java.math.BigDecimal.ONE,java.math.BigDecimal.TEN,java.math.BigDecimal.valueOf(1024));progression.increased(action,record,"acknowledged",java.math.BigDecimal.ONE);var unlearn=abilityEvents.begin(input,record,"fireball");abilityEvents.failed(unlearn,"save_failed");var definition=new games.dmg.fantasystick.passive.QuickerRegenerationI(games.dmg.fantasystick.passive.QuickerRegenerationI.Settings.defaults()).definition();var passive=passiveEvents.begin(input,record,definition,true);passiveEvents.failed(passive,"save_failed");});
}
@Test void registeredOpenReachesOneAcknowledgedFeatureEventAfterAuthoritativeReadAndNativeOpen() throws Exception {
@@ -112,7 +112,17 @@ class CharacterBookNativeTest {
assertEquals(saved, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(),
"A rendered purchase action is single-use");
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.opened")).count(),"The automatic post-purchase refresh must not duplicate a user-requested book-open outcome");
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.opened")).count(),"The automatic post-purchase refresh must not duplicate a user-requested book-open outcome");var increased=events.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")).toList();assertEquals(1,increased.size());var value=increased.getFirst().getAsJsonObject("data");assertEquals("CON",value.get("stat_id").getAsString());assertEquals("0",value.get("stat_before").getAsString());assertEquals("1",value.get("stat_after").getAsString());assertEquals("40",value.get("points_before").getAsString());assertEquals("39",value.get("points_after").getAsString());assertEquals("5.0",value.get("health_before").getAsString());assertEquals("6.0",value.get("health_after").getAsString());assertEquals("10.0",value.get("maximum_health_before").getAsString());assertEquals("11",value.get("maximum_health_after").getAsString());assertEquals("acknowledged",value.get("native_publication").getAsString());var action=value.get("action_id").getAsString();assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.observed")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("character_stat_increase")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("character_stat_increase")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());
}
@Test void everyPermanentStatPublishesExactlyOneAcknowledgedMutation() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.activate(a);PartyBookNativeTest.open(f,a);for(var stat:games.dmg.fantasystick.progression.Stat.values()){PartyBookNativeTest.click(f,a,PartyBookNativeTest.button(a,"[+1 "+stat+"]"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}f.plugin.setEnabled(false);f.awaitWorkers();}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var increased=events.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")).toList();assertEquals(6,increased.size());assertEquals(java.util.Arrays.stream(games.dmg.fantasystick.progression.Stat.values()).map(Enum::name).collect(java.util.stream.Collectors.toSet()),increased.stream().map(event->event.getAsJsonObject("data").get("stat_id").getAsString()).collect(java.util.stream.Collectors.toSet()));assertEquals(6,increased.stream().map(event->event.get("id").getAsString()).distinct().count());assertTrue(increased.stream().allMatch(event->event.getAsJsonObject("data").get("native_publication").getAsString().equals("acknowledged")));
}
@Test void conPurchaseAtTheNativeCeilingRecordsStatAndPointChangeWithoutInventingHealing() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var old=a.core.record;var stats=new java.util.EnumMap<games.dmg.fantasystick.progression.Stat,java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);stats.putAll(old.stats());stats.put(games.dmg.fantasystick.progression.Stat.CON,java.math.BigInteger.valueOf(1014));var prepared=new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),old.revision().add(java.math.BigInteger.ONE),stats,old.level(),old.experience(),java.math.BigInteger.ONE,java.math.BigDecimal.TEN,old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins(),old.coinsInitialized(),old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(prepared,old.revision());f.players.put(a.core.id,a.player);f.activate(a);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,PartyBookNativeTest.button(a,"[+1 CON]"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);f.plugin.setEnabled(false);f.awaitWorkers();}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var value=events.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")).map(event->event.getAsJsonObject("data")).findFirst().orElseThrow();assertEquals("1014",value.get("stat_before").getAsString());assertEquals("1015",value.get("stat_after").getAsString());assertEquals(0,new java.math.BigDecimal(value.get("maximum_health_before").getAsString()).compareTo(new java.math.BigDecimal(value.get("maximum_health_after").getAsString())));assertEquals(0,new java.math.BigDecimal(value.get("health_before").getAsString()).compareTo(new java.math.BigDecimal(value.get("health_after").getAsString())));
}
@Test void conPurchaseKeepsItsOwnMaximumAndOneTimeGainDespiteExternalModifiers() throws Exception {
@@ -185,6 +195,7 @@ class CharacterBookNativeTest {
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
assertEquals(saved.stats(), characters(f).find(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow().stats());
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var statEvents=events.stream().filter(event->event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("character_stat_increase")).toList();assertEquals(1,statEvents.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")&&event.getAsJsonObject("data").get("stat_id").getAsString().equals("STR")).count());assertTrue(statEvents.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")).map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("outcome").getAsString().equals("failed")&&value.get("reason").getAsString().equals("save_failed")));assertTrue(statEvents.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")).map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("outcome").getAsString().equals("refused")&&value.get("reason").getAsString().equals("stale_forged_or_repeated_action")));
}
@Test void nativePagesKeepLargeNumbersUnicodeNamesAndUnknownAbilitiesReadableAndComplete() throws Exception {
@@ -258,6 +258,12 @@ class FireballNativeTest {
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")),
"Transfer/reload must retain the original Fireball deadline after refund and relearn");
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();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())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var unlearned=events.stream().filter(event->event.get("type").getAsString().endsWith("ability.unlearned")).toList();assertEquals(1,unlearned.size());var value=unlearned.getFirst().getAsJsonObject("data");assertEquals("fireball",value.get("ability_id").getAsString());assertEquals("39",value.get("points_before").getAsString());assertEquals("40",value.get("points_after").getAsString());assertEquals(java.util.List.of(0),value.getAsJsonArray("cleared_slot_indexes").asList().stream().map(com.google.gson.JsonElement::getAsInt).toList());assertEquals("acknowledged",value.get("native_publication").getAsString());assertEquals("invalidation_acknowledged_close_attempted",value.get("owned_cleanup").getAsString());assertEquals(value.get("cooldown_before").getAsString(),value.get("cooldown_after").getAsString());assertTrue(value.getAsJsonObject("location").has("world_id"));var action=value.get("action_id").getAsString();assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.observed")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("ability_unlearn")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("ability_unlearn")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)&&event.getAsJsonObject("data").get("outcome").getAsString().equals("accepted")).count());assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("ability_unlearn")).map(event->event.getAsJsonObject("data")).anyMatch(data->data.get("outcome").getAsString().equals("refused")&&data.get("reason").getAsString().equals("stale_forged_or_repeated_action")));
}
@Test void failedBookUnlearnResolvesWithoutInventingARefundEvent() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withFireball(data,a,12);f.players.put(a.core.id,a.player);f.activate(a);PartyBookNativeTest.open(f,a);var unlearn=PartyBookNativeTest.button(a,"[Unlearn]");try(var channel=java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),java.nio.file.StandardOpenOption.WRITE);var lock=channel.lock()){assertTrue(lock.isValid());PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertTrue(storage.find(a.core.record.id()).get().orElseThrow().learned().contains("fireball"));f.plugin.setEnabled(false);f.awaitWorkers();}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();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())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(0,events.stream().filter(event->event.get("type").getAsString().endsWith("ability.unlearned")).count());assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("ability_unlearn")).map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("outcome").getAsString().equals("failed")&&value.get("reason").getAsString().equals("save_or_native_cleanup_uncertain")));
}
@Test void partyAndMixedModeProjectileHitsPreserveProtectionCancellation() throws Exception {
@@ -116,16 +116,18 @@ class OutOfCombatRecoveryNativeTest {
stale.clock.advance(Duration.ofSeconds(9)); stale.recovery.sweep(); assertEquals(1, stale.events.size());
}
@Test void eventSinkFailureCannotChangeAnAcceptedRecoveryPulse(){var f=new Direct();f.recovery.attachBonusHealing(ignored->BigInteger.valueOf(3));f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(event->{throw new IllegalStateException("disk");},f.clock),ignored->BigInteger.valueOf(3));assertDoesNotThrow(()->{f.recovery.sweep();f.clock.advance(Duration.ofSeconds(10));f.recovery.sweep();f.runNext();});assertEquals(8,f.health.get());assertEquals(0,new BigDecimal("8").compareTo(f.record.get().health()));}
@Test void passiveBonusZeroCancellationAndFullHealthStayInTheBasePulseTransaction() {
var zero = new Direct(); zero.recovery.attachBonusHealing(ignored -> BigInteger.ZERO);
zero.recovery.sweep(); zero.clock.advance(Duration.ofSeconds(10)); zero.recovery.sweep(); zero.runNext();
assertEquals(5, zero.health.get(), "A zero passive bonus leaves the base pulse unchanged");
var cancelled = new Direct(); cancelled.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); cancelled.veto = true;
var cancelled = new Direct(); cancelled.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3));var audit=new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();cancelled.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(audit::add,cancelled.clock),ignored->BigInteger.valueOf(3));cancelled.veto = true;
var cancelledRevision = cancelled.record.get().revision();
cancelled.recovery.sweep(); cancelled.clock.advance(Duration.ofSeconds(10)); cancelled.recovery.sweep();
assertEquals(4, cancelled.events.getFirst().getAmount());
assertEquals(4, cancelled.health.get()); assertEquals(cancelledRevision, cancelled.record.get().revision());
assertEquals(4, cancelled.health.get()); assertEquals(cancelledRevision, cancelled.record.get().revision());assertEquals(1,audit.stream().filter(event->event.type().equals("action.observed")).count());assertEquals(1,audit.stream().filter(event->event.type().equals("action.resolved")&&event.data().get("outcome").equals("refused")&&event.data().get("reason").equals("native_event_cancelled")).count());assertEquals(0,audit.stream().filter(event->event.type().equals("combat.healed")).count());
var full = new Direct(); full.health.set(10d); full.record.set(full.saved(BigDecimal.TEN));
full.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); var fullRevision = full.record.get().revision();
@@ -241,7 +243,7 @@ class OutOfCombatRecoveryNativeTest {
base.unspentPoints(), BigDecimal.valueOf(4), base.learned(), base.loadout(), base.cooldowns(),
base.restrictedKills(), false, null, null));
active = new EquipmentSessions.Active(java.util.UUID.randomUUID(), java.util.UUID.randomUUID(), base.id());
when(player.getUniqueId()).thenReturn(active.player()); when(player.getHealth()).thenAnswer(call -> health.get());
var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(java.util.UUID.randomUUID());when(world.getName()).thenReturn("world");when(player.getUniqueId()).thenReturn(active.player());when(player.getName()).thenReturn("Player");when(player.getLocation()).thenReturn(new org.bukkit.Location(world,0,64,0)); when(player.getHealth()).thenAnswer(call -> health.get());
doAnswer(call -> { health.set(call.getArgument(0)); return null; }).when(player).setHealth(anyDouble());
when(player.getAttribute(Attribute.MAX_HEALTH)).thenReturn(maximum); when(maximum.getValue()).thenReturn(10d);
when(sessions.active(active.player())).thenReturn(Optional.of(active));
@@ -94,6 +94,12 @@ class QuickerRegenerationNativeTest {
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
assertEquals(refunded.passives(), storage(f).find(refunded.id()).get().orElseThrow().passives());
}
var events=events(data);var learnedEvents=events.stream().filter(event->event.get("type").getAsString().endsWith("passive.learned")).toList();var unlearnedEvents=events.stream().filter(event->event.get("type").getAsString().endsWith("passive.unlearned")).toList();assertEquals(1,learnedEvents.size());assertEquals(1,unlearnedEvents.size());var learned=learnedEvents.getFirst().getAsJsonObject("data");assertEquals("quicker_regeneration_i",learned.get("passive_id").getAsString());assertEquals("40",learned.get("points_before").getAsString());assertEquals("39",learned.get("points_after").getAsString());assertEquals(0,learned.get("passive_slot").getAsInt());assertEquals("12",learned.getAsJsonObject("stat_prerequisites").get("CON").getAsString());var unlearned=unlearnedEvents.getFirst().getAsJsonObject("data");assertEquals("39",unlearned.get("points_before").getAsString());assertEquals("40",unlearned.get("points_after").getAsString());assertEquals("passive_state_acknowledged",unlearned.get("owned_cleanup").getAsString());var healed=events.stream().filter(event->event.get("type").getAsString().endsWith("combat.healed")).map(event->event.getAsJsonObject("data")).filter(value->value.has("recovery_kind")&&value.get("recovery_kind").getAsString().equals("out_of_combat")).toList();assertEquals(1,healed.size());assertEquals("4.0",healed.getFirst().get("accepted_total").getAsString());assertEquals("3.0",healed.getFirst().getAsJsonObject("passive_contributions").get("quicker_regeneration_i").getAsString());assertEquals("acknowledged",healed.getFirst().get("native_publication").getAsString());
}
@Test void failedPassiveSaveResolvesWithoutInventingLearnedStateOrSuccess() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);makeEligible(data,a,BigDecimal.valueOf(4),List.of("","",""));f.players.put(a.core.id,a.player);f.activate(a);PartyBookNativeTest.open(f,a);var pages=nativePages(a);var qri=pages.get(changePageTo(pages.get(first(pages,"Passive index")),pages,"Quicker Regeneration I").page()-1);var learn=custom(List.of(qri),"fantasystick:passive_unlock");try(var channel=java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),java.nio.file.StandardOpenOption.WRITE);var lock=channel.lock()){assertTrue(lock.isValid());PartyBookNativeTest.click(f,a,learn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}assertFalse(storage(f).find(a.core.record.id()).get().orElseThrow().passives().contains("quicker_regeneration_i"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);f.plugin.setEnabled(false);f.awaitWorkers();}
var events=events(data);assertEquals(0,events.stream().filter(event->event.get("type").getAsString().endsWith("passive.learned")).count());assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("passive_learn")).map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("outcome").getAsString().equals("failed")&&value.get("reason").getAsString().equals("save_or_native_publication_uncertain")),events::toString);
}
@Test void multiPagePassiveIndexAndWrappedDetailsReturnToTheirExactContainingPage() throws Exception {
@@ -176,9 +182,13 @@ class QuickerRegenerationNativeTest {
var settings = (games.dmg.fantasystick.persistence.QuickerRegenerationSettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "quickerRegenerationSettings");
assertEquals("4", settings.value());
try(var channel=java.nio.channels.FileChannel.open(data.resolve(".config.lock"),java.nio.file.StandardOpenOption.CREATE,java.nio.file.StandardOpenOption.WRITE);var lock=channel.lock()){assertTrue(lock.isValid());f.command.execute(sender.port,"fantasystick",new String[]{"config","quicker-regeneration-i-bonus-healing","5"});var failed=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(failed);failed.run();}assertEquals("4",settings.value());assertTrue(sender.messages.getLast().contains("save failed"));
}
var events=events(data);var reads=events.stream().filter(event->event.get("type").getAsString().endsWith("config.read")&&event.getAsJsonObject("data").get("key").getAsString().equals("quicker-regeneration-i-bonus-healing")).toList();var changes=events.stream().filter(event->event.get("type").getAsString().endsWith("config.changed")&&event.getAsJsonObject("data").get("key").getAsString().equals("quicker-regeneration-i-bonus-healing")).toList();assertEquals(1,reads.size());assertEquals("1",reads.getFirst().getAsJsonObject("data").get("after").getAsString());assertEquals(1,changes.size());assertEquals("1",changes.getFirst().getAsJsonObject("data").get("before").getAsString());assertEquals("4",changes.getFirst().getAsJsonObject("data").get("after").getAsString());assertEquals("future_recovery_pulses",changes.getFirst().getAsJsonObject("data").get("application").getAsString());
}
private static java.util.List<com.google.gson.JsonObject> events(Path data)throws Exception{var events=new java.util.ArrayList<com.google.gson.JsonObject>();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())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}return events;}
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health,
List<String> passives) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);