Compare commits

...
1 Commits
Author SHA1 Message Date
dmg db35c28f0b feat(nutrition): isolate fantasy hunger and healing
Release / release (push) Successful in 5m54s
2026-09-19 15:26:02 -04:00
5 changed files with 191 additions and 2 deletions

No files matched your search

@@ -45,6 +45,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.session.SessionVitals vitals;
private games.dmg.fantasystick.session.SessionVisuals sessionVisuals;
private games.dmg.fantasystick.combat.BasicAttack basicAttack;
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
@@ -109,6 +110,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
var player = getServer().getPlayer(id);
return player != null && player.isOnline();
@@ -143,6 +145,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(partyCombat, this);
partyCombat.install(this);
getServer().getPluginManager().registerEvents(localBasicAttack, this);
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
getServer().getPluginManager().registerEvents(books, this);
getServer().getPluginManager().registerEvents(gameModes, this);
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
@@ -181,6 +184,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingVitals = vitals; vitals = null;
var closingVisuals = sessionVisuals; sessionVisuals = null;
var closingBasicAttack = basicAttack; basicAttack = null;
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
// Retire client resources independently of successful ordinary-equipment restoration.
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
@@ -191,6 +195,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
}
if (closingBooks != null) cleanup("character book", closingBooks::close);
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
if (closingParties != null) cleanup("parties", closingParties::close);
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
@@ -203,6 +208,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingPartyEvents);
unregister(closingPartyCombat);
unregister(closingBasicAttack);
unregister(closingNutrition);
unregister(closingBooks);
unregister(closingEvents);
unregister(closingCrafting);
@@ -0,0 +1,48 @@
package games.dmg.fantasystick.session;
import java.util.Objects;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
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 boolean closed;
public NutritionPolicy(EquipmentSessions sessions) {
this.sessions = Objects.requireNonNull(sessions);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void food(org.bukkit.event.entity.FoodLevelChangeEvent event) {
if (!active(event.getEntity())) return;
event.setCancelled(true);
var player = (Player) event.getEntity();
player.setFoodLevel(20);
player.setExhaustion(0);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void exhaustion(org.bukkit.event.entity.EntityExhaustionEvent event) {
if (!active(event.getEntity())) return;
event.setCancelled(true);
var player = (Player) event.getEntity();
player.setFoodLevel(20);
player.setExhaustion(0);
}
@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);
}
private boolean active(org.bukkit.entity.Entity entity) {
return !closed && entity instanceof Player player
&& sessions.active(player.getUniqueId()).isPresent();
}
@Override public void close() { closed = true; }
}
@@ -57,10 +57,15 @@ public final class SessionVitals implements AutoCloseable {
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
player.setHealthScale(maximum);
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
player.setFoodLevel(20);
player.setExhaustion(0);
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
write(player, new Mark(session, Phase.APPLIED, maximum, ordinary));
} catch (RuntimeException | LinkageError failure) {
try { releaseMaximum(player, session); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
try { restoreNutrition(player, ordinary); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
try { restoreScale(player, maximum, ordinary); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
// Leave APPLYING proof and the acknowledged receipt: an uncertain health setter
@@ -121,7 +126,7 @@ public final class SessionVitals implements AutoCloseable {
// completion). Restore positive health first: a zero-health packet opens a client death
// screen even when Paper cancels the death and the server-side player survives.
player.setHealth(Math.min(ordinary.health(), Objects.requireNonNull(player.getAttribute(Attribute.MAX_HEALTH)).getValue()));
player.setFoodLevel(ordinary.food()); player.setSaturation(ordinary.saturation()); player.setExhaustion(ordinary.exhaustion());
restoreNutrition(player, ordinary);
restoreScale(player, mark.maximum(), ordinary);
write(player, new Mark(session, Phase.RELEASED, mark.maximum(), ordinary));
return true;
@@ -135,6 +140,12 @@ public final class SessionVitals implements AutoCloseable {
}
}
private static void restoreNutrition(Player player, OrdinaryVitals ordinary) {
player.setFoodLevel(ordinary.food());
player.setSaturation(ordinary.saturation());
player.setExhaustion(ordinary.exhaustion());
}
private static void restoreScale(Player player, double maximum, OrdinaryVitals ordinary) {
if (player.isHealthScaled() && player.getHealthScale() == maximum) {
player.setHealthScale(ordinary.scale());
@@ -65,6 +65,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
assertTrue(fixture.order.indexOf("BasicAttack") >= 0);
assertTrue(fixture.order.indexOf("BasicAttack") < recipe);
assertTrue(fixture.order.indexOf("NutritionPolicy") >= 0);
assertTrue(fixture.order.indexOf("NutritionPolicy") < recipe);
assertNotNull(fixture.command);
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
assertTrue(fixture.tasks.isEmpty());
@@ -1260,6 +1262,7 @@ class FantasyStickLifecycleNativeTest {
boolean healthScaled;
double healthScale = 20;
Runnable scaleMutation = () -> {};
Runnable foodMutation = () -> {};
java.util.function.Consumer<org.bukkit.event.player.PlayerGameModeChangeEvent> modeEvents = ignored -> { };
final org.bukkit.craftbukkit.entity.CraftPlayer player;
final net.minecraft.server.level.ServerPlayer nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
@@ -1293,7 +1296,7 @@ class FantasyStickLifecycleNativeTest {
}
case "getMaxHealth" -> maximumHealth.getValue();
case "getFoodLevel" -> food.getFoodLevel();
case "setFoodLevel" -> { food.setFoodLevel((Integer) args[0]); yield null; }
case "setFoodLevel" -> { food.setFoodLevel((Integer) args[0]); foodMutation.run(); yield null; }
case "getSaturation" -> food.getSaturationLevel();
case "setSaturation" -> { food.setSaturation((Float) args[0]); yield null; }
case "getExhaustion" -> food.exhaustionLevel;
@@ -0,0 +1,121 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class NutritionNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void exitEatingAndReentryCannotHealTheSavedCharacterOrLoseTheNewOrdinaryNutrition() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player);
a.player.setHealth(12); a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
f.activate(a); a.player.setHealth(4);
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(12, a.player.getHealth()); assertEquals(7, a.player.getFoodLevel());
a.player.setHealth(a.maximumHealth.getValue());
a.player.setFoodLevel(20); a.player.setSaturation(12); a.player.setExhaustion(0.5f);
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
assertEquals(4, a.player.getHealth(), "Ordinary eating/healing cannot refill transferable fantasy health");
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(a.maximumHealth.getValue(), a.player.getHealth());
assertEquals(20, a.player.getFoodLevel()); assertEquals(12, a.player.getSaturation());
assertEquals(0.5f, a.player.getExhaustion());
var characters = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, characters.find(a.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS)
.orElseThrow().health().compareTo(java.math.BigDecimal.valueOf(4)));
}
}
@Test void failedFantasyFoodSetterRestoresTheCapturedOrdinaryNutritionAndRetainsRecoveryProof() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player);
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
a.foodMutation = () -> {
a.foodMutation = () -> {};
throw new IllegalStateException("Injected food setter failure");
};
f.activate(a);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
assertTrue(sessions.active(a.core.id).isEmpty());
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
assertEquals(3, a.player.getExhaustion());
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)
.orElseThrow().pending());
}
}
@Test void fantasyDefeatRestoresOrdinaryNutritionInsteadOfLeavingTheFullFantasyState() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player);
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
f.activate(a);
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
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(a.player, source, new java.util.ArrayList<>(), 0,
net.kyori.adventure.text.Component.empty(), true);
f.dispatcher.accept(death); PartyBookNativeTest.drain(f);
assertTrue(death.isCancelled());
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
assertEquals(3, a.player.getExhaustion());
}
}
@Test void activeFantasyModeKeepsFoodFullBlocksFoodHealingAndRestoresOrdinaryNutrition() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data);
ordinary.core.inventory.setItemInOffHand(null);
f.players.put(a.core.id, a.player); f.players.put(ordinary.core.id, ordinary.player);
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
ordinary.player.setFoodLevel(8); ordinary.player.setExhaustion(4);
f.activate(a);
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
var drain = new org.bukkit.event.entity.FoodLevelChangeEvent(a.player, 19);
var exhaustion = new org.bukkit.event.entity.EntityExhaustionEvent(a.player,
org.bukkit.event.entity.EntityExhaustionEvent.ExhaustionReason.SPRINT, 0.1f);
var foodHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
var potionHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC);
var abilityHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.CUSTOM);
f.dispatcher.accept(drain); f.dispatcher.accept(exhaustion);
f.dispatcher.accept(foodHealing); f.dispatcher.accept(potionHealing); f.dispatcher.accept(abilityHealing);
assertTrue(drain.isCancelled()); assertTrue(exhaustion.isCancelled()); assertTrue(foodHealing.isCancelled());
assertFalse(potionHealing.isCancelled(), "Explicit native magic healing remains available");
assertFalse(abilityHealing.isCancelled(), "Explicit ability healing remains available");
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
var ordinaryDrain = new org.bukkit.event.entity.FoodLevelChangeEvent(ordinary.player, 7);
var ordinaryHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(ordinary.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
f.dispatcher.accept(ordinaryDrain); f.dispatcher.accept(ordinaryHealing);
assertFalse(ordinaryDrain.isCancelled()); assertFalse(ordinaryHealing.isCancelled());
assertEquals(8, ordinary.player.getFoodLevel()); assertEquals(4, ordinary.player.getExhaustion());
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
assertEquals(3, a.player.getExhaustion());
}
}
}