fix(recovery): preserve pulses when audit world unloads

This commit is contained in:
dmg committed 2026-09-26 10:38:33 -04:00
1 parent 9b378b4237
commit 912c077da1
2 files changed
+116 -2

No files matched your search

@@ -27,9 +27,9 @@ public final class RecoveryEventBridge {
public void refused(Action action,String reason){emit("action.resolved",action,"refused",reason,null,null,null,null);}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null,null,null);}
public void healed(Action action,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){emit("action.resolved",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);emit("combat.healed",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);}
private void emit(String type,Action action,String outcome,String reason,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){var data=new LinkedHashMap<String,Object>();data.put("action_kind","out_of_combat_recovery");data.put("recovery_kind","out_of_combat");data.put("cause","recovery_timer");data.put("expected_revision",action.expectedRevision().toString());data.put("health_before",action.healthBefore().toPlainString());data.put("proposed_total",action.proposed().toPlainString());data.put("configured_base",action.configuredBase().toString());data.put("configured_quicker_regeneration_i",action.configuredQuickerRegeneration().toString());data.put("outcome",outcome);if(healthAfter!=null)data.put("health_after",healthAfter.toPlainString());if(acceptedTotal!=null){data.put("accepted_total",acceptedTotal.toPlainString());data.put("base_contribution",baseContribution.toPlainString());if(passiveContribution.signum()>0)data.put("passive_contributions",Map.of("quicker_regeneration_i",passiveContribution.toPlainString()));data.put("native_publication","acknowledged");}if(reason!=null)data.put(type.equals("combat.healed")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
private void emit(String type,Action action,String outcome,String reason,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){var data=new LinkedHashMap<String,Object>();data.put("action_kind","out_of_combat_recovery");data.put("recovery_kind","out_of_combat");data.put("cause","recovery_timer");data.put("expected_revision",action.expectedRevision().toString());data.put("health_before",action.healthBefore().toPlainString());data.put("proposed_total",action.proposed().toPlainString());data.put("configured_base",action.configuredBase().toString());data.put("configured_quicker_regeneration_i",action.configuredQuickerRegeneration().toString());data.put("outcome",outcome);if(action.context().location()==null)data.put("location_status","unavailable");if(healthAfter!=null)data.put("health_after",healthAfter.toPlainString());if(acceptedTotal!=null){data.put("accepted_total",acceptedTotal.toPlainString());data.put("base_contribution",baseContribution.toPlainString());if(passiveContribution.signum()>0)data.put("passive_contributions",Map.of("quicker_regeneration_i",passiveContribution.toPlainString()));data.put("native_publication","acknowledged");}if(reason!=null)data.put(type.equals("combat.healed")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(Player player,EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
private EventInput.Context context(Player player,EquipmentSessions.Active active,String name){EventInput.Location captured;try{captured=location(player.getLocation());}catch(RuntimeException unavailable){captured=null;}return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),captured);}
private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:recovery:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -118,6 +118,78 @@ class OutOfCombatRecoveryNativeTest {
@Test void eventSinkFailureCannotChangeAnAcceptedRecoveryPulse(){var f=new Direct();f.recovery.attachBonusHealing(ignored->BigInteger.valueOf(3));f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(event->{throw new IllegalStateException("disk");},f.clock),ignored->BigInteger.valueOf(3));assertDoesNotThrow(()->{f.recovery.sweep();f.clock.advance(Duration.ofSeconds(10));f.recovery.sweep();f.runNext();});assertEquals(8,f.health.get());assertEquals(0,new BigDecimal("8").compareTo(f.record.get().health()));}
@Test void unloadedAuditWorldCannotInterruptAcceptedOrRefusedRecovery() {
var f = new Direct();
var captured = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(captured::add, f.clock), ignored -> BigInteger.ZERO);
var location = mock(org.bukkit.Location.class);
when(location.getWorld()).thenThrow(new IllegalArgumentException("World unloaded"));
when(f.player.getLocation()).thenReturn(location);
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(10));
assertDoesNotThrow(() -> { f.recovery.sweep(); f.runNext(); });
assertEquals(5, f.health.get());
assertEquals(0, BigDecimal.valueOf(5).compareTo(f.record.get().health()));
assertEquals(List.of("action.observed", "action.resolved", "combat.healed"),
captured.stream().map(games.dmg.fantasystick.events.EventInput::type).toList());
captured.forEach(input -> {
assertEquals(f.active.player(), input.context().playerId());
assertEquals(f.active.character(), input.context().characterId());
assertEquals(f.active.session(), input.context().sessionId());
assertNull(input.context().location());
assertEquals("unavailable", input.data().get("location_status"));
});
f.clock.advance(Duration.ofSeconds(9)); f.recovery.sweep(); assertEquals(1, f.events.size());
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep(); f.runNext();
assertEquals(6, f.health.get(), "The next interval is not suppressed or replayed");
var cancelled = new Direct(); cancelled.veto = true;
var refusals = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
cancelled.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(refusals::add, cancelled.clock), ignored -> BigInteger.ZERO);
when(cancelled.player.getLocation()).thenReturn(location);
cancelled.recovery.sweep(); cancelled.clock.advance(Duration.ofSeconds(10)); cancelled.recovery.sweep();
assertEquals(4, cancelled.health.get());
assertEquals(List.of("action.observed", "action.resolved"),
refusals.stream().map(games.dmg.fantasystick.events.EventInput::type).toList());
assertEquals("refused", refusals.getLast().data().get("outcome"));
assertEquals("unavailable", refusals.getLast().data().get("location_status"));
}
@Test void availableAuditLocationRetainsItsExactWorldAndCoordinates() {
var f = new Direct();
var captured = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(captured::add, f.clock), ignored -> BigInteger.ZERO);
var world = mock(org.bukkit.World.class); var worldId = java.util.UUID.randomUUID();
when(world.getUID()).thenReturn(worldId); when(world.getName()).thenReturn("known-world");
var location = mock(org.bukkit.Location.class);
when(location.getWorld()).thenReturn(world);
when(location.getX()).thenReturn(2.5); when(location.getY()).thenReturn(65d); when(location.getZ()).thenReturn(-3d);
when(f.player.getLocation()).thenReturn(location);
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(10)); f.recovery.sweep(); f.runNext();
assertEquals(5, f.health.get());
assertEquals(worldId, captured.getFirst().context().location().worldId());
assertEquals("known-world", captured.getFirst().context().location().worldName());
assertEquals(2.5, captured.getFirst().context().location().x());
assertEquals(65d, captured.getFirst().context().location().y());
assertEquals(-3d, captured.getFirst().context().location().z());
assertFalse(captured.getFirst().data().containsKey("location_status"));
}
@Test void auditLocationCaptureFailureAndFailedSaveNeverClaimHealing() {
var f = new Direct(); f.automatic = false;
var captured = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(captured::add, f.clock), ignored -> BigInteger.ZERO);
when(f.player.getLocation()).thenThrow(new IllegalStateException("location inaccessible"));
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(10));
assertDoesNotThrow(f.recovery::sweep);
assertNotNull(f.pending);
f.pending.completeExceptionally(new java.io.IOException("save failed")); f.runNext();
assertEquals(4, f.health.get());
assertEquals(List.of("action.observed", "action.resolved"),
captured.stream().map(games.dmg.fantasystick.events.EventInput::type).toList());
assertEquals("failed", captured.getLast().data().get("outcome"));
assertEquals("unavailable", captured.getLast().data().get("location_status"));
}
@Test void passiveBonusZeroCancellationAndFullHealthStayInTheBasePulseTransaction() {
var zero = new Direct(); zero.recovery.attachBonusHealing(ignored -> BigInteger.ZERO);
zero.recovery.sweep(); zero.clock.advance(Duration.ofSeconds(10)); zero.recovery.sweep(); zero.runNext();
@@ -168,6 +240,48 @@ class OutOfCombatRecoveryNativeTest {
}
}
@Test void registeredRecoveryWithUnloadedAuditWorldPersistsAndExportsUnavailableLocation() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 10\n");
java.util.UUID playerId; java.util.UUID characterId;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); setHealth(data, a, BigDecimal.valueOf(4));
playerId = a.core.id; characterId = a.core.record.id();
f.players.put(playerId, a.player); f.activate(a);
var location = mock(org.bukkit.Location.class);
when(location.getWorld()).thenThrow(new IllegalArgumentException("World unloaded"));
doReturn(location).when(a.player).getLocation();
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
assertDoesNotThrow(recovery::sweep);
doReturn(new org.bukkit.Location(a.world, 0, 64, 0)).when(a.player).getLocation();
PartyBookNativeTest.drain(f);
assertEquals(5, a.player.getHealth());
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, BigDecimal.valueOf(5).compareTo(storage.find(characterId).get().orElseThrow().health()));
}
var healed = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var segment : files.filter(file -> file.getFileName().toString().startsWith("events-")
&& file.getFileName().toString().endsWith(".jsonl")).toList()) {
for (var line : java.nio.file.Files.readAllLines(segment)) {
if (line.isBlank()) continue;
var event = com.google.gson.JsonParser.parseString(line).getAsJsonObject();
if (event.get("type").getAsString().endsWith("combat.healed")
&& event.getAsJsonObject("data").has("recovery_kind")) healed.add(event);
}
}
}
assertEquals(1, healed.size());
var event = healed.getFirst(); var payload = event.getAsJsonObject("data");
assertEquals(playerId.toString(), payload.get("player_id").getAsString());
assertEquals(characterId.toString(), payload.get("character_id").getAsString());
assertTrue(payload.has("session_id"));
assertEquals("unavailable", payload.get("location_status").getAsString());
assertFalse(payload.has("location"));
assertEquals("5.0", payload.get("health_after").getAsString());
}
@Test void packagedPluginConnectsZeroDelayRecoveryConfigurationAndRestart() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 10\nout-of-combat-healing-amount: 1\n");