feat(nutrition): audit native healing and restoration
Release / release (push) Successful in 17m20s

This commit is contained in:
dmg committed 2026-09-25 20:52:29 -04:00
1 parent fcbc2814c2
commit 830049b339
6 files changed
+953 -13

No files matched your search

@@ -177,6 +177,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private games.dmg.fantasystick.events.SessionEventBridge sessionAuditEvents;
private games.dmg.fantasystick.events.NutritionEventBridge nutritionAuditEvents;
private java.util.concurrent.CompletionStage<Void> nutritionAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchAuditEvents;
private java.util.concurrent.CompletionStage<Void> switchAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityAuditEvents;
@@ -188,6 +190,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.MessageEventBridge messageAuditEvents;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
private void dispatchOwnedHealing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
var policy = nutritionPolicy;
if (policy == null) getServer().getPluginManager().callEvent(event);
else policy.dispatchOwnedHealing(event, getServer().getPluginManager()::callEvent);
}
@Override public void onEnable() {
try {
Object generation = new Object();
@@ -441,7 +449,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
secondWindSettings = new games.dmg.fantasystick.persistence.SecondWindSettings(
getDataFolder().toPath(), secondWindConfiguration);
secondWind = new games.dmg.fantasystick.ability.SecondWind(secondWindInitial, characters, sessions, nextTick,
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read);
this::dispatchOwnedHealing, games.dmg.fantasystick.session.NativeHealthLimit::read);
stoneSkinSettings = new games.dmg.fantasystick.persistence.StoneSkinSettings(
getDataFolder().toPath(), stoneSkinConfiguration);
combatMitigation = new games.dmg.fantasystick.ability.CombatMitigation();
@@ -462,7 +470,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
mendingLightSettings = new games.dmg.fantasystick.persistence.MendingLightSettings(
getDataFolder().toPath(), mendingLightConfiguration);
mendingLight = new games.dmg.fantasystick.ability.MendingLight(mendingLightInitial, characters, sessions, parties,
nextTick, event -> getServer().getPluginManager().callEvent(event),
nextTick, this::dispatchOwnedHealing,
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
purifySettings = new games.dmg.fantasystick.persistence.PurifySettings(getDataFolder().toPath(), purifyConfiguration);
@@ -488,7 +496,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
sanctuarySettings = new games.dmg.fantasystick.persistence.SanctuarySettings(
getDataFolder().toPath(), sanctuaryConfiguration);
sanctuary = new games.dmg.fantasystick.ability.Sanctuary(sanctuaryInitial, characters, sessions, parties,
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
combatMitigation, nextTick, this::dispatchOwnedHealing,
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()),
particleDelivery);
@@ -528,11 +536,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
var gearItems=new games.dmg.fantasystick.items.GearItems(this);sessions.attachGear(gearItems);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions, nextTick);
sessions.attachNutritionPolicy(nutritionPolicy);
outOfCombatRecoverySettings = new games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings(
getDataFolder().toPath(), recoveryConfiguration);
outOfCombatRecovery = new games.dmg.fantasystick.session.OutOfCombatRecovery(recoveryInitial, characters, sessions,
Clock.systemUTC(), nextTick, event -> getServer().getPluginManager().callEvent(event),
Clock.systemUTC(), nextTick, this::dispatchOwnedHealing,
games.dmg.fantasystick.session.NativeHealthLimit::read,
problem -> getLogger().log(Level.WARNING,
"Out-of-combat recovery failed for character " + problem.character(), problem.cause()), combatFeedback);
@@ -545,7 +554,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
healthStealSettings = new games.dmg.fantasystick.persistence.HealthStealSettings(
getDataFolder().toPath(), healthStealInitial.healing().toString());
healthSteal = new games.dmg.fantasystick.passive.HealthSteal(healthStealInitial, characters, sessions, nextTick,
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read,
this::dispatchOwnedHealing, games.dmg.fantasystick.session.NativeHealthLimit::read,
problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()),
combatFeedback);
speedI = new games.dmg.fantasystick.passive.SpeedI(sessions);
@@ -618,7 +627,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
rareShopMenus=new games.dmg.fantasystick.shop.RareShopMenus(this,rareShop);var localRareMenus=rareShopMenus;
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;if(rareEvents!=null)rareEvents.catalogRefreshed(catalog);try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
rareMerchantController=new games.dmg.fantasystick.shop.RareMerchantController(this,rareMerchants,rareShopMenus::open);
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,event->getServer().getPluginManager().callEvent(event),nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,this::dispatchOwnedHealing,nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
rareReskills=new games.dmg.fantasystick.shop.RareReskillService(rareInventory,characters,sessions,nextTick,record->{for(String ability:record.learned())abilityEngine.remove(record.id(),ability);speedI.sweep();lowLightVision.sweep();},failure->getLogger().log(Level.WARNING,"Rare Reskill use failed",failure));
rareMagicCores=new games.dmg.fantasystick.shop.RareMagicCoreService(rareInventory,characters,sessions,nextTick,failure->getLogger().log(Level.WARNING,"Rare Magic Core use failed",failure));
rarePotions.install();rareReskills.install();rareMagicCores.install();books.attachRareInventory(rareInventory,rarePotions,rareReskills,rareMagicCores);
@@ -760,6 +769,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC());combatExperience.attachEvents(combatRewardEvents);
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC());characters.attachCoinEvents(coinEvents);admin.attachCoinEvents(coinEvents);
sessionAuditEvents = new games.dmg.fantasystick.events.SessionEventBridge(eventLog::emit,Clock.systemUTC());sessions.attachSessionEvents(sessionAuditEvents);
nutritionAuditEvents = new games.dmg.fantasystick.events.NutritionEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Nutrition event intent failed", failure));
sessions.attachNutritionEvents(nutritionAuditEvents);
nutritionPolicy.attachEvents(nutritionAuditEvents);
switchAuditEvents = new games.dmg.fantasystick.events.SwitchDelayEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Switch deadline event intent failed", failure));
@@ -790,7 +804,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
id -> presentation.snapshot().get(id),sessionAuditEvents,eventLog::emit,Clock.systemUTC());
getServer().getPluginManager().registerEvents(messageAuditEvents,this);
} catch (java.io.IOException failure) {
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; switchAuditEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -871,6 +885,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
failure -> getLogger().log(Level.WARNING, "Recipe discovery event intent failed", failure));
recipeDiscovery.attachEvents(recipeEvents);
eventLog.ready(); // Queue startup recovery before any recipe event handoff.
var replayNutritionEvents = nutritionAuditEvents;
nutritionAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous nutrition event intent drain failed", failure);
if (replayNutritionEvents != null) replayNutritionEvents.replay();
});
var replaySwitchEvents = switchAuditEvents;
switchAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous switch event intent drain failed", failure);
@@ -929,6 +948,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
@@ -1237,6 +1257,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
if (closingSessions != null) cleanup("equipment sessions", closingSessions::close);
if (closingRecipeDiscovery != null) cleanup("recipe discovery event intents", closingRecipeDiscovery::close);
if (closingNutritionAuditEvents != null) cleanup("nutrition event intent drain", () -> {
var sessionsDrained = closingSessions == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
nutritionAuditDrain = sessionsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Nutrition owner drain failed", failure);
closingNutritionAuditEvents.close();
return null;
}).thenCompose(ignored -> closingNutritionAuditEvents.drained());
});
if (closingSwitchAuditEvents != null) cleanup("switch event intent drain", () -> {
var sessionsDrained = closingSessions == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
@@ -0,0 +1,159 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.OrdinaryVitals;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Captures nutrition at the native boundary; success follows an acknowledged session receipt. */
public final class NutritionEventBridge implements AutoCloseable {
public record Capture(EventInput.Context context, UUID session, UUID character, Instant time,
int food, float saturation, float exhaustion, String reason) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final FeatureEventOutbox outbox;
private final Clock clock;
public NutritionEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
outbox = new FeatureEventOutbox(data, "nutrition-event-intents",
Set.of("nutrition.suspended", "nutrition.restored"), sink, failures);
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
public Capture capture(Player player, UUID session, UUID character, String name, String reason) {
try {
var now = clock.instant();
var where = location(player.getLocation(), now);
return new Capture(new EventInput.Context(player.getUniqueId(), player.getName(),
name == null ? null : character, name, name == null ? null : session, where),
session, character, now, player.getFoodLevel(), player.getSaturation(), player.getExhaustion(), reason);
} catch (RuntimeException ignored) { return null; }
}
public void suspended(Player player, UUID session, CharacterRecord character, OrdinaryVitals ordinary,
BigInteger receiptRevision) {
if (ordinary == null || receiptRevision == null) return;
var captured = capture(player, session, character.id(), character.name(), "entry");
if (captured == null) return;
var data = fields(captured, receiptRevision);
data.put("ordinary_food", ordinary.food());
data.put("ordinary_saturation", (double) ordinary.saturation());
data.put("ordinary_exhaustion", (double) ordinary.exhaustion());
data.put("fantasy_food", captured.food());
data.put("fantasy_saturation", (double) captured.saturation());
data.put("fantasy_exhaustion", (double) captured.exhaustion());
data.put("outcome", "acknowledged");
durable(captured, "nutrition.suspended", "suspended", data);
}
public void restored(Capture before, Capture after, OrdinaryVitals ordinary, BigInteger receiptRevision) {
if (before == null || after == null || ordinary == null || receiptRevision == null
|| !before.session().equals(after.session())) return;
var data = fields(before, receiptRevision);
data.put("before_food", before.food());
data.put("before_saturation", (double) before.saturation());
data.put("before_exhaustion", (double) before.exhaustion());
data.put("restored_food", after.food());
data.put("restored_saturation", (double) after.saturation());
data.put("restored_exhaustion", (double) after.exhaustion());
data.put("outcome", "acknowledged");
durable(after, "nutrition.restored", "restored", data);
}
public void satiatedRefused(Player player, UUID session, UUID character, String name, double amount) {
var captured = capture(player, session, character, name, "satiated_regeneration");
if (captured == null) return;
var action = UUID.randomUUID();
for (var stage : new String[]{"observed", "resolved"}) {
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "satiated_regeneration");
data.put("session_id", session.toString());
data.put("character_id", character.toString());
data.put("requested_amount", amount);
data.put("regain_source", "SATIATED");
data.put("outcome", stage.equals("observed") ? "observed" : "cancelled");
if (stage.equals("resolved")) data.put("reason", "nutrition_policy");
try { sink.apply(new EventInput(id(action, stage), action, captured.time(),
"action." + stage, captured.context(), data)); }
catch (RuntimeException ignored) { /* Logging never changes a native cancellation. */ }
}
}
public void restorationRetained(Capture captured, String reason) {
if (captured == null) return;
var action = UUID.randomUUID();
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "nutrition_restore");
data.put("session_id", captured.session().toString());
data.put("character_id", captured.character().toString());
data.put("outcome", "retained");
data.put("reason", reason);
try { sink.apply(new EventInput(id(action, "retained"), action, captured.time(),
"action.resolved", captured.context(), data)); }
catch (RuntimeException ignored) { }
}
/** Native event alone is not success; a later observed health change proves an applied effect. */
public void nativeHealed(Capture captured, double before, double after, String source) {
if (captured == null || !Double.isFinite(before) || !Double.isFinite(after) || after <= before) return;
var action = UUID.randomUUID();
var data = new LinkedHashMap<String, Object>();
data.put("session_id", captured.session().toString());
data.put("character_id", captured.character().toString());
data.put("regain_source", source);
data.put("health_before", before);
data.put("health_after", after);
data.put("actual_amount", after - before);
data.put("persistence_result", "pending_session_health_save");
data.put("publication_result", "native_health_observed");
data.put("outcome", "applied");
try { sink.apply(new EventInput(id(action, "healed"), action, captured.time(),
"combat.healed", captured.context(), data)); }
catch (RuntimeException ignored) { /* An event never authorizes a health change. */ }
}
private LinkedHashMap<String, Object> fields(Capture captured, BigInteger revision) {
var data = new LinkedHashMap<String, Object>();
data.put("session_id", captured.session().toString());
data.put("character_id", captured.character().toString());
data.put("receipt_id", captured.session().toString());
data.put("receipt_revision", revision.toString());
data.put("reason", captured.reason());
return data;
}
private void durable(Capture captured, String type, String phase, LinkedHashMap<String, Object> data) {
try { outbox.accepted(new EventInput(id(captured.session(), phase), captured.session(), captured.time(),
type, captured.context(), data)); }
catch (RuntimeException ignored) { /* Audit failure cannot change nutrition custody. */ }
}
private EventInput.Location location(Location value, Instant now) {
return value == null || value.getWorld() == null ? null
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
value.getZ(), now, "exact");
}
private static UUID id(UUID session, String phase) {
return UUID.nameUUIDFromBytes(("fantasystick:nutrition:" + session + ':' + phase)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -48,6 +48,8 @@ public final class EquipmentSessions implements AutoCloseable {
private java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> downedRewardAllocation=ignored->List.of();
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.SessionEventBridge sessionEvents;
private games.dmg.fantasystick.events.NutritionEventBridge nutritionEvents;
private NutritionPolicy nutritionPolicy;
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchEvents;
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentEvents;
@@ -122,6 +124,10 @@ public final class EquipmentSessions implements AutoCloseable {
boolean exitRequested;
games.dmg.fantasystick.events.SessionEventBridge.ExitCapture exitEvent;
boolean exitEventWritten;
games.dmg.fantasystick.events.NutritionEventBridge.Capture nutritionBeforeRestore;
games.dmg.fantasystick.events.NutritionEventBridge.Capture nutritionAfterRestore;
boolean nutritionRestoreWritten;
boolean nutritionRetainedWritten;
games.dmg.fantasystick.events.SwitchDelayEventBridge.ExitContext switchExit;
boolean switchEventWritten;
String exitReason = "voluntary";
@@ -230,6 +236,8 @@ public final class EquipmentSessions implements AutoCloseable {
public synchronized void attachDownedRewardAllocation(java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> allocation){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedRewardAllocation=Objects.requireNonNull(allocation);}
public synchronized void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(!owned.isEmpty()||downedEvents!=null)throw new IllegalStateException("Downed events already attached or sessions started");downedEvents=Objects.requireNonNull(events);}
public synchronized void attachSessionEvents(games.dmg.fantasystick.events.SessionEventBridge events){if(!owned.isEmpty()||sessionEvents!=null)throw new IllegalStateException("Session events already attached or sessions started");sessionEvents=Objects.requireNonNull(events);}
public synchronized void attachNutritionEvents(games.dmg.fantasystick.events.NutritionEventBridge events){if(!owned.isEmpty()||nutritionEvents!=null)throw new IllegalStateException("Nutrition events already attached or sessions started");nutritionEvents=Objects.requireNonNull(events);}
public synchronized void attachNutritionPolicy(NutritionPolicy policy){if(!owned.isEmpty()||nutritionPolicy!=null)throw new IllegalStateException("Nutrition policy already attached or sessions started");nutritionPolicy=Objects.requireNonNull(policy);}
public synchronized void attachSwitchEvents(games.dmg.fantasystick.events.SwitchDelayEventBridge events){if(!owned.isEmpty()||switchEvents!=null)throw new IllegalStateException("Switch events already attached or sessions started");switchEvents=Objects.requireNonNull(events);}
public synchronized void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events){if(!owned.isEmpty()||identityEvents!=null)throw new IllegalStateException("Identity events already attached or sessions started");identityEvents=Objects.requireNonNull(events);}
public synchronized void attachEquipmentEvents(games.dmg.fantasystick.events.EquipmentEventBridge events){if(!owned.isEmpty()||equipmentEvents!=null)throw new IllegalStateException("Equipment events already attached or sessions started");equipmentEvents=Objects.requireNonNull(events);}
@@ -748,6 +756,8 @@ public final class EquipmentSessions implements AutoCloseable {
}
attempt.player.updateInventory();
if(active.get(attempt.owner)==attempt&&sessionEvents!=null)sessionEvents.entered(attempt.player,attempt.session,record,attempt.entryLocation,attempt.resumedDowned!=null,attempt.previousGameMode!=null);
if(active.get(attempt.owner)==attempt&&nutritionEvents!=null)nutritionEvents.suspended(
attempt.player, attempt.session, record, attempt.ordinaryVitals, attempt.packReceiptRevision);
if(active.get(attempt.owner)==attempt&&identityEvents!=null)identityEvents.entered(attempt.player,attempt.session,record);
} catch (RuntimeException error) {
fail(attempt, error); // Effects may have started: never roll back or replay them.
@@ -794,6 +804,8 @@ public final class EquipmentSessions implements AutoCloseable {
return EquipmentStorage.RestoreResult.NOT_FOUND;
}
cancelEntry(owner);
var current = active.get(owner);
if (current != null && nutritionPolicy != null) nutritionPolicy.flushNativeHealing(current.player);
var attempt = active.remove(owner);
if (attempt == null) { invalidateCursor(owner); return EquipmentStorage.RestoreResult.NOT_FOUND; }
attempt.exitReason = Objects.requireNonNull(reason);
@@ -1061,6 +1073,7 @@ public final class EquipmentSessions implements AutoCloseable {
if(sessionEvents!=null&&attempt.exitEvent==null&&!attempt.defeated&&attempt.downed==null&&!closed&&attempt.player.isOnline()&&attempt.record!=null)
attempt.exitEvent=sessionEvents.captureExit(attempt.player,attempt.session,attempt.record,attempt.entryLocation);
captureExitDelay(attempt);
if (nutritionPolicy != null) nutritionPolicy.flushNativeHealing(attempt.player);
active.remove(attempt.owner, attempt);
withdrawPresentation(attempt);
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
@@ -1201,6 +1214,7 @@ public final class EquipmentSessions implements AutoCloseable {
private void recoveryIncomplete(Attempt attempt, String reason) {
if (attempt.recoveryResolved && attempt.sessionRecoveryResolved) return;
nutritionRetained(attempt, reason);
if (equipmentEvents != null && attempt.recoveryAction != null && !attempt.recoveryResolved) {
attempt.recoveryResolved = true;
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
@@ -1229,11 +1243,34 @@ public final class EquipmentSessions implements AutoCloseable {
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
}
private void nutritionRetained(Attempt attempt, String reason) {
if (nutritionEvents == null || attempt.ordinaryVitals == null || attempt.nutritionRetainedWritten) return;
var captured = attempt.nutritionBeforeRestore == null
? nutritionEvents.capture(attempt.player, attempt.session, attempt.character,
attempt.record == null ? null : attempt.record.name(), attempt.exitReason)
: attempt.nutritionBeforeRestore;
if (captured == null) return;
attempt.nutritionRetainedWritten = true;
nutritionEvents.restorationRetained(captured, reason);
}
private boolean restoreVitals(Attempt attempt) {
if (vitals == null || attempt.ordinaryVitals == null) return true;
boolean applied = nutritionEvents != null && vitals.nutritionRestorationReady(
attempt.player, attempt.session, attempt.ordinaryVitals);
var before = nutritionEvents == null ? null : nutritionEvents.capture(attempt.player,
attempt.session, attempt.character, attempt.record == null ? null : attempt.record.name(), attempt.exitReason);
try {
if (vitals.restore(attempt.player, attempt.session, attempt.ordinaryVitals)) return true;
if (vitals.restore(attempt.player, attempt.session, attempt.ordinaryVitals)) {
if (applied && before != null) {
attempt.nutritionBeforeRestore = before;
attempt.nutritionAfterRestore = nutritionEvents.capture(attempt.player, attempt.session,
attempt.character, attempt.record == null ? null : attempt.record.name(), attempt.exitReason);
}
return true;
}
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
nutritionRetained(attempt, "native_restoration_unverified");
notice(attempt.owner, "VITALS_RECOVERY", "Health/nutrition restoration was refused or ambiguous; retained receipt requires inspection.");
return false;
}
@@ -1255,6 +1292,7 @@ public final class EquipmentSessions implements AutoCloseable {
: receipts.recordExit(attempt.owner, attempt.session, attempt.exitDelay), (state, failure) -> {
attempt.saving = false;
if (failure != null) {
nutritionRetained(attempt, "receipt_save_failed");
sessionReconciled(attempt,
attempt.nativeRestored ? "restored" : "pending",
attempt.nativeRestored ? "ambiguous" : "failed",
@@ -1287,14 +1325,21 @@ public final class EquipmentSessions implements AutoCloseable {
attempt.switchEventWritten = true;
switchEvents.started(attempt.switchExit, state);
}
if (!attempt.nutritionRestoreWritten && nutritionEvents != null
&& attempt.nutritionAfterRestore != null) {
attempt.nutritionRestoreWritten = true;
nutritionEvents.restored(attempt.nutritionBeforeRestore, attempt.nutritionAfterRestore,
attempt.ordinaryVitals, state.revision());
}
if (!attempt.defeated && !attempt.exitEventWritten && attempt.exitEvent != null && sessionEvents != null) {
attempt.exitEventWritten = true;
sessionEvents.exited(attempt.session,attempt.exitEvent);
}
if (attempt.defeated) queueTail(attempt);
release(attempt);
} else if (attempt.defeated) {
conversionUncertain(attempt,
} else {
nutritionRetained(attempt, !attempt.healthAcknowledged ? "health_unverified" : "receipt_incomplete");
if (attempt.defeated) conversionUncertain(attempt,
"Equipment restoration was not acknowledged; Tail conversion remains unsafe");
}
});
@@ -9,10 +9,39 @@ import org.bukkit.event.Listener;
/** Active-session-only hunger/exhaustion suppression; explicit non-food healing remains native. */
public final class NutritionPolicy implements Listener, AutoCloseable {
private final EquipmentSessions sessions;
private final java.util.function.Consumer<Runnable> nextTick;
private final java.util.Set<org.bukkit.event.entity.EntityRegainHealthEvent> owned =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
private interface PendingHealing extends Runnable { void flush(); }
private final java.util.Map<java.util.UUID, PendingHealing> pendingNativeHealing = new java.util.HashMap<>();
private final java.util.Set<java.util.UUID> ambiguousNativeHealing = new java.util.HashSet<>();
private final java.util.Set<org.bukkit.event.entity.EntityRegainHealthEvent> suppressed =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
private games.dmg.fantasystick.events.NutritionEventBridge events;
private boolean closed;
public NutritionPolicy(EquipmentSessions sessions) {
this(sessions, ignored -> { });
}
public NutritionPolicy(EquipmentSessions sessions, java.util.function.Consumer<Runnable> nextTick) {
this.sessions = Objects.requireNonNull(sessions);
this.nextTick = Objects.requireNonNull(nextTick);
}
/** Explicit provenance for a plugin-owned heal that another feature owner audits. */
public void dispatchOwnedHealing(org.bukkit.event.entity.EntityRegainHealthEvent event,
java.util.function.Consumer<? super org.bukkit.event.entity.EntityRegainHealthEvent> dispatcher) {
if (event.getEntity() instanceof Player player && pendingNativeHealing.containsKey(player.getUniqueId()))
ambiguousNativeHealing.add(player.getUniqueId());
owned.add(event);
try { dispatcher.accept(event); }
finally { owned.remove(event); }
}
public void attachEvents(games.dmg.fantasystick.events.NutritionEventBridge bridge) {
if (events != null) throw new IllegalStateException("Nutrition events already attached");
events = Objects.requireNonNull(bridge);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -36,7 +65,74 @@ public final class NutritionPolicy implements Listener, AutoCloseable {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void healing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
if (event.getRegainReason() == org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED
&& active(event.getEntity())) event.setCancelled(true);
&& active(event.getEntity())) {
event.setCancelled(true);
suppressed.add(event);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedHealing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
boolean blocked = suppressed.remove(event);
if (events == null || !(event.getEntity() instanceof Player player)) return;
var active = sessions.active(player.getUniqueId()).orElse(null);
if (active == null) return;
var record = sessions.currentRecord(active).orElse(null);
if (blocked) {
if (event.isCancelled()) events.satiatedRefused(player, active.session(), active.character(),
record == null ? null : record.name(), event.getAmount());
return;
}
if (event.isCancelled() || owned.contains(event) || !Double.isFinite(event.getAmount())
|| event.getAmount() <= 0 || record == null) return;
var owner = player.getUniqueId();
if (pendingNativeHealing.containsKey(owner)) {
ambiguousNativeHealing.add(owner);
return;
}
double before = player.getHealth();
double accepted = event.getAmount();
String source = event.getRegainReason().name();
var captured = events.capture(player, active.session(), active.character(), record.name(), "native_regain");
PendingHealing completion = new PendingHealing() {
@Override public void run() { finish(false); }
@Override public void flush() { finish(true); }
private void finish(boolean exiting) {
if (!pendingNativeHealing.remove(owner, this)) return;
boolean ambiguous = ambiguousNativeHealing.remove(owner);
if (closed || ambiguous || captured == null) return;
try {
boolean stillOwned = exiting
? sessions.active(owner).filter(active::equals).isPresent() && player.isOnline()
: sessions.current(active);
if (!stillOwned) return;
double after = player.getHealth();
double delta = after - before;
if (Double.isFinite(delta) && delta > 0 && delta <= accepted)
events.nativeHealed(captured, before, after, source);
} catch (RuntimeException ignored) {
// An audit observation cannot interrupt native healing or session exit.
}
}
};
pendingNativeHealing.put(owner, completion);
try { nextTick.accept(completion); }
catch (RuntimeException failure) { pendingNativeHealing.remove(owner, completion); }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void concurrentDamage(org.bukkit.event.entity.EntityDamageEvent event) {
if (event.getEntity() instanceof Player player && pendingNativeHealing.containsKey(player.getUniqueId()))
ambiguousNativeHealing.add(player.getUniqueId());
}
/** Complete a proved native heal before the owning session is released in the same tick. */
public void flushNativeHealing(Player player) {
var pending = pendingNativeHealing.get(player.getUniqueId());
if (pending != null) {
try { pending.flush(); }
catch (RuntimeException ignored) { /* Never interrupt native exit for an audit. */ }
}
}
private boolean active(org.bukkit.entity.Entity entity) {
@@ -44,5 +140,5 @@ public final class NutritionPolicy implements Listener, AutoCloseable {
&& sessions.active(player.getUniqueId()).isPresent();
}
@Override public void close() { closed = true; }
@Override public void close() { closed = true; suppressed.clear(); owned.clear(); pendingNativeHealing.clear(); ambiguousNativeHealing.clear(); events = null; }
}
@@ -106,6 +106,13 @@ public final class SessionVitals implements AutoCloseable {
return BigDecimal.valueOf(Math.max(0, Math.min(player.getHealth(), mark.maximum())));
}
/** Only an applied marker permits a newly observed native nutrition restoration. */
public boolean nutritionRestorationReady(Player player, UUID session, OrdinaryVitals ordinary) {
var mark = read(player);
return ordinary != null && mark != null && mark.session().equals(session)
&& mark.ordinary().equals(ordinary) && mark.phase() == Phase.APPLIED;
}
public boolean restore(Player player, UUID session, OrdinaryVitals ordinary) {
if (ordinary == null) return true; // Legacy equipment-only receipt, not health restoration authority.
var mark = read(player);
@@ -0,0 +1,604 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered session and native nutrition policy -> exported JSONL, not direct bridge calls. */
class NutritionEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void acknowledgedEntryAndExitExportOneOwnedNutritionTransitionEach() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
actor.player.setSaturation(2);
actor.player.setExhaustion(3);
fixture.activate(actor);
assertEquals(20, actor.player.getFoodLevel());
awaitType("nutrition.suspended");
actor.core.inventory.setItemInOffHand(null);
fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor);
assertEquals(7, actor.player.getFoodLevel());
awaitType("nutrition.restored");
fixture.plugin.setEnabled(false);
var suspended = matching("nutrition.suspended");
var restored = matching("nutrition.restored");
assertEquals(1, suspended.size()); assertEquals(1, restored.size());
var start = suspended.getFirst().getAsJsonObject("data");
var end = restored.getFirst().getAsJsonObject("data");
assertEquals(actor.core.id.toString(), suspended.getFirst().get("playerid").getAsString());
assertEquals(actor.core.record.id().toString(), suspended.getFirst().get("characterid").getAsString());
assertEquals(start.get("session_id"), end.get("session_id"));
assertEquals(7, start.get("ordinary_food").getAsInt());
assertEquals(2, start.get("ordinary_saturation").getAsFloat());
assertEquals(3, start.get("ordinary_exhaustion").getAsFloat());
assertEquals(20, start.get("fantasy_food").getAsInt());
assertEquals(7, end.get("restored_food").getAsInt());
assertEquals("acknowledged", end.get("outcome").getAsString());
assertTrue(start.has("receipt_id"));
assertTrue(end.has("receipt_id"));
assertTrue(suspended.getFirst().getAsJsonObject("data").has("location"));
}
}
@Test void restartedRecoveryWithoutHealthProofRetainsNutritionWithoutInventedSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
sessions.exit(actor.player));
fixture.awaitCharacterAndReceipt(actor);
fixture.plugin.setEnabled(false); fixture.awaitWorkers();
actor.core.inventory.setItem(0, null);
fixture.plugin.setEnabled(true);
awaitNutritionResolution(fixture, "retained", "health_unverified");
fixture.plugin.setEnabled(false);
assertEquals(0, matching("nutrition.restored").size());
assertTrue(matching("action.resolved").stream().map(event -> event.getAsJsonObject("data"))
.anyMatch(fields -> fields.has("action_kind")
&& fields.get("action_kind").getAsString().equals("nutrition_restore")
&& fields.get("reason").getAsString().equals("health_unverified")));
}
}
@Test void retainedEquipmentExitDoesNotClaimNutritionSuccessUntilProofBasedRecoveryAcknowledges() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
sessions.exit(actor.player));
fixture.awaitCharacterAndReceipt(actor);
assertEquals(0, matching("nutrition.restored").size());
var attempt = ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "owned"))
.get(actor.core.id);
assertNotNull(attempt);
for (int i = 0; i < 8 && (boolean) FantasyStickLifecycleNativeTest.Fixture.field(attempt, "saving"); i++) {
fixture.awaitCharacterAndReceipt(actor);
fixture.tick();
}
assertTrue((boolean) FantasyStickLifecycleNativeTest.Fixture.field(attempt, "nutritionRetainedWritten"),
"Refused receipt must resolve native nutrition as retained before retry");
awaitNutritionResolution(fixture, "retained");
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending());
actor.core.inventory.setItem(0, null);
sessions.recover(actor.player);
awaitTypeWithCallbacks(fixture, "nutrition.restored");
fixture.plugin.setEnabled(false);
assertEquals(1, matching("nutrition.restored").size());
assertEquals(7, matching("nutrition.restored").getFirst().getAsJsonObject("data")
.get("restored_food").getAsInt());
}
}
@Test void acknowledgedRestoreSurvivesWriterOutageWithItsOriginalEventIdentity() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
awaitType("nutrition.suspended");
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor);
fixture.plugin.setEnabled(false); fixture.awaitWorkers();
java.util.Set<String> pending;
try (var files = Files.list(data.resolve("nutrition-event-intents"))) {
pending = files.filter(path -> path.getFileName().toString().endsWith(".pending"))
.map(path -> path.getFileName().toString().replace(".pending", ""))
.collect(java.util.stream.Collectors.toSet());
}
assertEquals(1, pending.size(), "A restored native snapshot awaits export, not another restoration");
fixture.plugin.setEnabled(true);
awaitType("nutrition.restored");
fixture.plugin.setEnabled(false);
assertEquals(pending, matching("nutrition.restored").stream()
.map(event -> event.get("id").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(1, matching("nutrition.restored").size());
}
}
@Test void finalDefeatRestorationIsNotMisreportedAsLivingExit() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
var death = new org.bukkit.event.entity.PlayerDeathEvent(actor.player, source, new ArrayList<>(), 0,
net.kyori.adventure.text.Component.empty(), true);
fixture.dispatcher.accept(death);
DownedSessionNativeTest.acceptDeath(fixture, actor);
PartyBookNativeTest.drain(fixture);
assertEquals(7, actor.player.getFoodLevel());
awaitTypeWithCallbacks(fixture, "nutrition.restored");
fixture.plugin.setEnabled(false);
var restored = matching("nutrition.restored");
assertEquals(1, restored.size());
assertEquals("defeat", restored.getFirst().getAsJsonObject("data").get("reason").getAsString());
assertEquals(0, matching("character.exited").size());
}
}
@Test void orderlyDisableReplaysAcknowledgedRestorationAfterLoggerShutdown() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
awaitType("nutrition.suspended");
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
fixture.plugin.setEnabled(false); fixture.awaitWorkers();
assertEquals(7, actor.player.getFoodLevel());
assertEquals(0, matching("nutrition.restored").size(),
"A best-effort native setter alone does not acknowledge the receipt");
assertEquals(1, matching("nutrition.suspended").size());
fixture.plugin.setEnabled(true);
awaitType("nutrition.restored");
fixture.plugin.setEnabled(false);
assertEquals(1, matching("nutrition.restored").size());
}
}
@Test void logoutAndDisableNeverClaimTwoSuccessfulRestorations() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(actor.player,
net.kyori.adventure.text.Component.empty()));
fixture.awaitCharacterAndReceipt(actor);
awaitTypeWithCallbacks(fixture, "nutrition.restored");
fixture.plugin.setEnabled(false);
assertEquals(1, matching("nutrition.restored").size());
assertEquals("logout", matching("nutrition.restored").getFirst().getAsJsonObject("data")
.get("reason").getAsString());
}
}
@Test void transferredSheetUsesNewHoldersOrdinaryNutritionAndCapturedIdentity() throws Exception {
Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var first = actor(fixture);
var next = actor(fixture);
next.core.inventory.setItemInOffHand(null);
first.player.setFoodLevel(7);
next.player.setFoodLevel(11);
fixture.activate(first);
var sheet = first.core.inventory.getItemInOffHand().clone();
first.core.inventory.setItemInOffHand(null); fixture.changed(first);
fixture.awaitCharacterAndReceipt(first);
awaitTypeWithCallbacks(fixture, "nutrition.restored");
next.core.inventory.setItemInOffHand(sheet);
fixture.activate(next);
assertEquals(20, next.player.getFoodLevel());
next.core.inventory.setItemInOffHand(null); fixture.changed(next);
fixture.awaitCharacterAndReceipt(next);
for (int i = 0; i < 250 && matching("nutrition.restored").size() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
assertEquals(2, matching("nutrition.suspended").size());
assertEquals(2, matching("nutrition.restored").size());
var transferred = matching("nutrition.suspended").stream().filter(event ->
event.get("playerid").getAsString().equals(next.core.id.toString())).findFirst().orElseThrow();
assertEquals(first.core.record.id().toString(), transferred.get("characterid").getAsString());
assertEquals(11, transferred.getAsJsonObject("data").get("ordinary_food").getAsInt());
assertEquals(7, first.player.getFoodLevel());
assertEquals(11, next.player.getFoodLevel());
}
}
@Test void exitingToEatAndReenteringRecordsTwoIndependentOrdinarySnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
var sheet = actor.core.inventory.getItemInOffHand().clone();
actor.player.setHealth(4);
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor); awaitType("nutrition.restored");
actor.player.setFoodLevel(15);
actor.core.inventory.setItemInOffHand(sheet); fixture.reenter(actor);
assertEquals(4, actor.player.getHealth());
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor);
for (int i = 0; i < 250 && matching("nutrition.restored").size() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var suspended = matching("nutrition.suspended");
var restored = matching("nutrition.restored");
assertEquals(2, suspended.size()); assertEquals(2, restored.size());
assertEquals(2, suspended.stream().map(event -> event.getAsJsonObject("data")
.get("session_id").getAsString()).distinct().count());
assertEquals(java.util.Set.of(7, 15), suspended.stream().map(event -> event.getAsJsonObject("data")
.get("ordinary_food").getAsInt()).collect(java.util.stream.Collectors.toSet()));
assertEquals(java.util.Set.of(7, 15), restored.stream().map(event -> event.getAsJsonObject("data")
.get("restored_food").getAsInt()).collect(java.util.stream.Collectors.toSet()));
}
}
@Test void satiatedRegainIsRefusedWithoutFoodTickSpamOrOrdinaryPlayerAudit() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
var ordinary = actor(fixture);
ordinary.core.inventory.setItemInOffHand(null);
fixture.activate(actor);
var exhaustion = new org.bukkit.event.entity.EntityExhaustionEvent(actor.player,
org.bukkit.event.entity.EntityExhaustionEvent.ExhaustionReason.SPRINT, 0.1f);
fixture.dispatcher.accept(exhaustion);
assertTrue(exhaustion.isCancelled());
var foodHeal = new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
fixture.dispatcher.accept(foodHeal);
assertTrue(foodHeal.isCancelled());
var ordinaryHeal = new org.bukkit.event.entity.EntityRegainHealthEvent(ordinary.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
fixture.dispatcher.accept(ordinaryHeal);
assertFalse(ordinaryHeal.isCancelled());
awaitType("action.resolved");
fixture.plugin.setEnabled(false);
var resolved = matching("action.resolved").stream().filter(event ->
event.getAsJsonObject("data").has("action_kind")
&& event.getAsJsonObject("data").get("action_kind").getAsString().equals("satiated_regeneration"))
.toList();
assertEquals(1, resolved.size());
assertEquals("cancelled", resolved.getFirst().getAsJsonObject("data").get("outcome").getAsString());
assertEquals(2, resolved.getFirst().getAsJsonObject("data").get("requested_amount").getAsDouble());
assertTrue(resolved.getFirst().getAsJsonObject("data").has("location"));
assertEquals(0, matching("combat.healed").size());
}
}
@Test void nonOwnedNativeMagicHealingReportsOnlyAppliedAmountAfterContinuation() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
var accepted = new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 3,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC);
fixture.dispatcher.accept(accepted);
assertFalse(accepted.isCancelled());
actor.player.setHealth(6); // The actual native health result, not the requested amount.
fixture.tick();
awaitType("combat.healed");
fixture.plugin.setEnabled(false);
var healed = matching("combat.healed");
assertEquals(1, healed.size());
var payload = healed.getFirst().getAsJsonObject("data");
assertEquals("MAGIC", payload.get("regain_source").getAsString());
assertEquals(4.0, payload.get("health_before").getAsDouble());
assertEquals(6.0, payload.get("health_after").getAsDouble());
assertEquals(2.0, payload.get("actual_amount").getAsDouble());
assertEquals("pending_session_health_save", payload.get("persistence_result").getAsString());
assertEquals("native_health_observed", payload.get("publication_result").getAsString());
assertEquals(actor.core.record.id().toString(), healed.getFirst().get("characterid").getAsString());
assertTrue(payload.has("location"));
}
}
@Test void failedAuditHealthReadCannotPreventNativeSessionExit() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
actor.player.setHealth(4);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
actor.player.setHealth(6);
var reads = new java.util.concurrent.atomic.AtomicInteger();
org.mockito.Mockito.doAnswer(call -> {
if (reads.incrementAndGet() == 1) throw new IllegalStateException("Audit health read unavailable");
return actor.core.player.getHealth();
}).when(actor.player).getHealth();
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor);
awaitTypeWithCallbacks(fixture, "nutrition.restored");
assertEquals(7, actor.player.getFoodLevel(), "Logging must not block ordinary nutrition restoration");
fixture.plugin.setEnabled(false);
assertEquals(0, matching("combat.healed").size(), "An unavailable native result is not invented");
}
}
@Test void sameTickExitStillReportsProvedNativeHealingBeforeTheSessionIsReleased() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
actor.player.setHealth(6);
var policy = (games.dmg.fantasystick.session.NutritionPolicy)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "nutritionPolicy");
assertTrue(((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(policy,
"pendingNativeHealing")).containsKey(actor.core.id));
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor);
awaitTypeWithCallbacks(fixture, "nutrition.restored");
fixture.plugin.setEnabled(false);
assertEquals(1, matching("combat.healed").size(),
"The actual native heal must not disappear when sheet exit follows in the same tick");
assertEquals(2.0, matching("combat.healed").getFirst().getAsJsonObject("data")
.get("actual_amount").getAsDouble());
}
}
@Test void nativeHealingDisclosesPendingSaveUntilSessionExitPersistsTheActualHealth() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
actor.player.setHealth(6);
fixture.tick(); awaitType("combat.healed");
var healed = matching("combat.healed").getFirst();
assertEquals("pending_session_health_save", healed.getAsJsonObject("data")
.get("persistence_result").getAsString());
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor);
awaitTypeWithCallbacks(fixture, "nutrition.restored");
var characters = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
assertEquals(0, characters.find(actor.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS)
.orElseThrow().health().compareTo(java.math.BigDecimal.valueOf(6)));
fixture.plugin.setEnabled(false);
assertEquals(1, matching("combat.healed").size());
}
}
@Test void externallyOwnedCustomRegainIsNotMistakenForThisPluginsHealing() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.CUSTOM));
actor.player.setHealth(6);
fixture.tick();
awaitType("combat.healed");
fixture.plugin.setEnabled(false);
assertEquals("CUSTOM", matching("combat.healed").getFirst().getAsJsonObject("data")
.get("regain_source").getAsString());
}
}
@Test void cancelledAndUnappliedNativeHealingAndOwnedCustomCauseDoNotMintSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
var cancelled = new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 3,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC);
cancelled.setCancelled(true);
fixture.dispatcher.accept(cancelled);
var unapplied = new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC);
fixture.dispatcher.accept(unapplied);
var owned = new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.CUSTOM);
var policy = (games.dmg.fantasystick.session.NutritionPolicy)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "nutritionPolicy");
policy.dispatchOwnedHealing(owned, fixture.dispatcher);
fixture.tick();
fixture.plugin.setEnabled(false);
assertEquals(0, matching("combat.healed").size());
}
}
@Test void ownedHealBetweenNativeObservationAndContinuationCannotBeMisattributed() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 3,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
var policy = (games.dmg.fantasystick.session.NutritionPolicy)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "nutritionPolicy");
policy.dispatchOwnedHealing(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.CUSTOM), fixture.dispatcher);
actor.player.setHealth(6);
fixture.tick();
fixture.plugin.setEnabled(false);
assertEquals(0, matching("combat.healed").size());
}
}
@Test void competingNativeHealObservationsCannotBeMisattributedToTheFirstRequest() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC_REGEN));
actor.player.setHealth(6);
fixture.tick();
fixture.plugin.setEnabled(false);
assertEquals(0, matching("combat.healed").size(),
"An overlapping native continuation has no attributable per-effect delta");
}
}
@Test void interveningDamageCannotBeMistakenForAnActualNativeHealAmount() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 3,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
actor.player.setHealth(7);
var damage = new org.bukkit.event.entity.EntityDamageEvent(actor.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE,
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
fixture.dispatcher.accept(damage);
assertFalse(damage.isCancelled());
actor.player.setHealth(6);
fixture.tick();
fixture.plugin.setEnabled(false);
assertEquals(0, matching("combat.healed").size());
}
}
@Test void failedNativeFoodSetterCannotClaimSuccessfulSuspensionOrRestoration() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
actor.foodMutation = () -> {
actor.foodMutation = () -> {};
throw new IllegalStateException("Injected food setter failure");
};
fixture.activate(actor);
fixture.plugin.setEnabled(false);
assertEquals(0, matching("nutrition.suspended").size());
assertEquals(0, matching("nutrition.restored").size());
}
}
@Test void incompleteNativeRestorationKeepsReceiptAndRecordsRetainedNotSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
actor.foodMutation = () -> {
actor.foodMutation = () -> {};
throw new IllegalStateException("Injected restoration food setter failure");
};
actor.core.inventory.setItemInOffHand(null);
fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor);
awaitNutritionResolution(fixture, "retained");
var state = fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNotNull(state.pending());
fixture.plugin.setEnabled(false);
assertEquals(0, matching("nutrition.restored").size());
}
}
private void awaitNutritionResolution(FantasyStickLifecycleNativeTest.Fixture fixture, String outcome) throws Exception {
awaitNutritionResolution(fixture, outcome, null);
}
private void awaitNutritionResolution(FantasyStickLifecycleNativeTest.Fixture fixture,
String outcome, String reason) throws Exception {
for (int i = 0; i < 250 && !nutritionResolution(outcome, reason); i++) {
var task = fixture.tasks.poll(20, java.util.concurrent.TimeUnit.MILLISECONDS);
if (task == null) Thread.sleep(20);
else task.run();
}
assertTrue(nutritionResolution(outcome, reason),
"No nutrition resolution " + outcome + "/" + reason + "; recovery=" + matching("recovery.resolved"));
}
private boolean nutritionResolution(String outcome, String reason) throws Exception {
return matching("action.resolved").stream().map(event -> event.getAsJsonObject("data"))
.anyMatch(fields -> fields.has("action_kind")
&& fields.get("action_kind").getAsString().equals("nutrition_restore")
&& fields.get("outcome").getAsString().equals(outcome)
&& (reason == null || fields.get("reason").getAsString().equals(reason)));
}
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture fixture) throws Exception {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
return actor;
}
private void awaitTypeWithCallbacks(FantasyStickLifecycleNativeTest.Fixture fixture, String suffix) throws Exception {
for (int i = 0; i < 250 && matching(suffix).isEmpty(); i++) {
var task = fixture.tasks.poll(20, java.util.concurrent.TimeUnit.MILLISECONDS);
if (task != null) task.run();
}
assertFalse(matching(suffix).isEmpty(), "No acknowledged " + suffix + " after native callbacks");
}
private void awaitType(String suffix) throws Exception {
for (int i = 0; i < 250 && matching(suffix).isEmpty(); i++) Thread.sleep(20);
assertFalse(matching(suffix).isEmpty(), "No exported " + suffix + " from registered native path");
}
private List<JsonObject> matching(String suffix) throws Exception {
return records().stream().filter(value -> value.get("type").getAsString().endsWith(suffix)).toList();
}
private List<JsonObject> records() throws Exception {
var found = new ArrayList<JsonObject>();
var directory = data.resolve("events");
if (!Files.exists(directory)) return found;
try (var days = Files.list(directory)) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path);
int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList())
if (!line.isBlank()) found.add(JsonParser.parseString(line).getAsJsonObject());
}
}
}
}
return found;
}
}