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);