This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user