From c2f4ac46addec00191496586af0fc24f7abe812c Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:11:30 +0200 Subject: [PATCH] Run the pet estate pass once ownership is ready after a restart The hourly estate pass first runs on tick 1, before reviewed heirs are ready, so after every restart it answered RETRY ("Reviewed pet ownership and persistence are not ready") over estates it had already settled, and pets waited up to an hour for the next pass. The pass now skips quietly until ownership is ready (a persistence conflict is still reported as blocked), and the heir wait runs it once as soon as ownership becomes ready. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/companionpets/PetsPlugin.java | 5 ++++- .../companionpets/estate/PetEstate.java | 16 ++++++++++++---- .../companionpets/PetsPluginLifecycleTest.java | 5 +++++ .../companionpets/estate/PetEstateTest.java | 14 ++++++++++++++ 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/tfminecraft/companionpets/PetsPlugin.java b/src/main/java/net/tfminecraft/companionpets/PetsPlugin.java index e7e60cf..f7b3dd7 100644 --- a/src/main/java/net/tfminecraft/companionpets/PetsPlugin.java +++ b/src/main/java/net/tfminecraft/companionpets/PetsPlugin.java @@ -49,6 +49,7 @@ public class PetsPlugin extends JavaPlugin { private BukkitTask visualTicker; private BukkitTask tailTicker; private BukkitTask heirWait; + private net.tfminecraft.companionpets.estate.PetEstate estate; private PetVisual visual; private PetRuntime runtime; private PetListener petListener; @@ -93,7 +94,7 @@ public void onEnable() { if (net.tfminecraft.companionpets.ownership.CharacterAccess.available()) Bukkit.getPluginManager().registerEvents(new net.tfminecraft.companionpets.ownership.CharacterLifecycle(runtime, actions), this); if (net.tfminecraft.companionpets.estate.EstateProbe.available()) - net.tfminecraft.companionpets.estate.PetEstate.start(this, runtime, actions); + estate = net.tfminecraft.companionpets.estate.PetEstate.start(this, runtime, actions); staff = new net.tfminecraft.companionpets.staff.StaffCommands(runtime, actions); tests = new net.tfminecraft.companionpets.staff.TestCommands(runtime, actions, staff); Bukkit.getPluginManager().registerEvents(staff.menus(), this); @@ -123,6 +124,8 @@ public void onEnable() { if (body != null && type != null) runtime.bodies().reattach(body, pet, type); } heirWait.cancel(); + // The first hourly estate pass ran before ownership was ready; settle due estates now. + if (estate != null) estate.reconcile(); }, 1L, 20L); } getLogger().info("CompanionPets enabled (" + config.types().size() + " pet types)"); diff --git a/src/main/java/net/tfminecraft/companionpets/estate/PetEstate.java b/src/main/java/net/tfminecraft/companionpets/estate/PetEstate.java index 4e71bf4..f77ca8c 100644 --- a/src/main/java/net/tfminecraft/companionpets/estate/PetEstate.java +++ b/src/main/java/net/tfminecraft/companionpets/estate/PetEstate.java @@ -34,11 +34,12 @@ public final class PetEstate implements EstateParticipant { public PetEstate(PetRuntime runtime, PetActions actions, Path journals) { this.runtime = runtime; this.actions = actions; this.journals = journals; } - public static void start(Plugin plugin, PetRuntime runtime, PetActions actions) { + public static PetEstate start(Plugin plugin, PetRuntime runtime, PetActions actions) { PetEstate estate = new PetEstate(runtime, actions, plugin.getDataFolder().toPath().resolve("estates")); estate.recover(); Bukkit.getServicesManager().register(EstateParticipant.class, estate, plugin, ServicePriority.Normal); Bukkit.getScheduler().runTaskTimer(plugin, estate::reconcile, 1L, 72000L); + return estate; } @Override public String id() { return "companionpets"; } @Override public Holdings describe(UUID character) { @@ -60,8 +61,7 @@ static String houseLabel(String key, String main) { } @Override public Outcome settle(EstateDecision decision) { if (runtime.store().persistenceConflict()) return Outcome.blocked("Pet source changed outside its current writer; review is required"); - if (!net.tfminecraft.companionpets.ownership.CharacterAccess.heirReady() || !runtime.store().ownershipReady()) - return Outcome.retry("Reviewed pet ownership and persistence are not ready"); + if (!ready()) return Outcome.retry("Reviewed pet ownership and persistence are not ready"); if (!guard.test(decision)) return Outcome.retry("The exact estate decision is not current"); if (decision.property().character().isPresent() && runtime.store().hasUnassignedOwner(decision.testator().ownerId())) return Outcome.blocked("Legacy pet/house ownership for this account still needs its reviewed heir"); @@ -301,7 +301,15 @@ private void recover() { runtime.plugin().getLogger().severe("Pet estate journals unreadable; ownership stays held: " + ex.getMessage()); } } - void reconcile() { + private boolean ready() { + return net.tfminecraft.companionpets.ownership.CharacterAccess.heirReady() && runtime.store().ownershipReady(); + } + /** + * Hourly, and once more when ownership becomes ready after a restart. Until then the pass is + * skipped rather than answering RETRY over estates already settled; a conflict still reports. + */ + public void reconcile() { + if (!runtime.store().persistenceConflict() && !ready()) return; for (EstateDecision decision : decided.get()) { if (guard.test(decision)) Estates.report(decision.deathId(), id(), decision.revision(), settle(decision)); } diff --git a/src/test/java/net/tfminecraft/companionpets/PetsPluginLifecycleTest.java b/src/test/java/net/tfminecraft/companionpets/PetsPluginLifecycleTest.java index 9dcb678..fa8332d 100644 --- a/src/test/java/net/tfminecraft/companionpets/PetsPluginLifecycleTest.java +++ b/src/test/java/net/tfminecraft/companionpets/PetsPluginLifecycleTest.java @@ -125,9 +125,14 @@ private WolfMock body(Pet pet, Location at) { org.mockito.Mockito.when(identity.legacyHeirsReady()).thenAnswer(call->ready.get()); org.mockito.Mockito.when(identity.legacyHeir(owner.getUniqueId())).thenReturn(java.util.Optional.of(new net.tfminecraft.tlibs.character.CharacterRef(owner.getUniqueId(),owner.getUniqueId()))); org.bukkit.Bukkit.getServicesManager().register(net.tfminecraft.tlibs.character.CharacterIdentity.class,identity,plugin,org.bukkit.plugin.ServicePriority.Highest); + var estates=org.mockito.Mockito.mock(net.tfminecraft.tlibs.character.estate.EstateService.class); + org.bukkit.Bukkit.getServicesManager().register(net.tfminecraft.tlibs.character.estate.EstateService.class,estates,plugin,org.bukkit.plugin.ServicePriority.Highest); enable();var existing=runtime().store().get(pet.id());assertNull(existing.ownerCharacterId());server.getScheduler().performOneTick();assertNull(existing.ownerCharacterId()); + org.mockito.Mockito.verify(estates,org.mockito.Mockito.never()).decided(); ready.set(true);server.getScheduler().performTicks(20);assertSame(existing,runtime().store().get(pet.id()));assertEquals(owner.getUniqueId(),existing.ownerCharacterId()); assertTrue(Files.exists(file("ownership-migrations/plan-0001.yml"))); + // The estate pass skipped while heirs were pending runs once as soon as they are ready. + org.mockito.Mockito.verify(estates).decided(); } @Test void recoveryGuardDisablesStartupAndPreservesFilesWithoutSchedulingActions() throws Exception { diff --git a/src/test/java/net/tfminecraft/companionpets/estate/PetEstateTest.java b/src/test/java/net/tfminecraft/companionpets/estate/PetEstateTest.java index fd5c3ff..81fd095 100644 --- a/src/test/java/net/tfminecraft/companionpets/estate/PetEstateTest.java +++ b/src/test/java/net/tfminecraft/companionpets/estate/PetEstateTest.java @@ -331,6 +331,20 @@ void unrelatedIncompleteDeathDoesNotBlockThisExactHeldIntentResume() throws Exce estate.decided=()->List.of(decision);estate.guard=ignored->false;estate.reconcile();org.mockito.Mockito.verifyNoInteractions(service); estate.guard=ignored->true;estate.reconcile();org.mockito.Mockito.verify(service).report(org.mockito.ArgumentMatchers.eq(death),org.mockito.ArgumentMatchers.eq("companionpets"),org.mockito.ArgumentMatchers.eq(1L),org.mockito.ArgumentMatchers.any(Outcome.class)); } + @Test void hourlyPassWaitsSilentlyForReadinessButStillReportsAPersistenceConflict() throws Exception { + var service=org.mockito.Mockito.mock(EstateService.class); + org.bukkit.Bukkit.getServicesManager().register(EstateService.class,service,runtime.plugin(),org.bukkit.plugin.ServicePriority.Highest); + var decision=decision(1,Recipient.FROZEN);estate.decided=()->List.of(decision); + var identity=org.mockito.Mockito.mock(net.tfminecraft.tlibs.character.CharacterIdentity.class);var ready=new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.when(identity.legacyHeirsReady()).thenAnswer(call->ready.get()); + org.bukkit.Bukkit.getServicesManager().register(net.tfminecraft.tlibs.character.CharacterIdentity.class,identity,runtime.plugin(),org.bukkit.plugin.ServicePriority.Highest); + estate.reconcile();org.mockito.Mockito.verifyNoInteractions(service); + ready.set(true);estate.reconcile(); + org.mockito.Mockito.verify(service).report(org.mockito.ArgumentMatchers.eq(death),org.mockito.ArgumentMatchers.eq("companionpets"),org.mockito.ArgumentMatchers.eq(1L),org.mockito.ArgumentMatchers.isA(Outcome.Done.class)); + ready.set(false);Path file=runtime.plugin().getDataFolder().toPath().resolve("pets.yml"); + Files.writeString(file,Files.readString(file)+"external-progress: kept\n");assertFalse(store.save());estate.reconcile(); + org.mockito.Mockito.verify(service).report(org.mockito.ArgumentMatchers.eq(death),org.mockito.ArgumentMatchers.eq("companionpets"),org.mockito.ArgumentMatchers.eq(1L),org.mockito.ArgumentMatchers.isA(Outcome.Blocked.class)); + } @Test void malformedJournalBlocksButFilesystemFailureRemainsRetryable() throws Exception { var decision=decision(1,Recipient.character(to));assertInstanceOf(Outcome.Done.class,estate.settle(decision)); Path file=folder.resolve(death+"-1.yml");String original=Files.readString(file);