diff --git a/src/main/java/net/tfminecraft/magic/gear/ownership/CharacterGearEstate.java b/src/main/java/net/tfminecraft/magic/gear/ownership/CharacterGearEstate.java index 58e8bc2..50e0c73 100644 --- a/src/main/java/net/tfminecraft/magic/gear/ownership/CharacterGearEstate.java +++ b/src/main/java/net/tfminecraft/magic/gear/ownership/CharacterGearEstate.java @@ -58,16 +58,26 @@ public Holdings describe(UUID character) { return ids.isEmpty() ? Holdings.NONE : new Holdings(Map.of("gear", ids.size()), ids); } catch (IOException | RuntimeException ex) { return new Holdings(Map.of("heldGear", 1), List.of("Magic gear needs review")); } } + /** + * Decided estates stay decided for good, and each settle reads the station file several times, + * so a revision this process already reported DONE is not settled again until the next restart. + * RETRY and BLOCKED answers, and new revisions, are still settled every hour. Gear that reaches a + * settled testator later, such as a staff-recovered station, moves at the first pass after a restart. + */ void reconcile() { try { GearStationStore.property().migrate(); GearStationStore.loadViews(); } catch (IOException ex) { GearStationStore.warning(ex); } for (EstateDecision decision : Estates.decided()) { try { - if (!Estates.guardHolds(decision)) continue; + if (settled(decision) || !Estates.guardHolds(decision)) continue; Outcome outcome = settle(decision); var result = Map.entry(decision.revision(), outcome); if (!result.equals(reported.get(decision.deathId())) && Estates.report(decision.deathId(), id(), decision.revision(), outcome)) reported.put(decision.deathId(), result); } catch (RuntimeException ex) { plugin.getLogger().warning("[Magic] Gear estate held: " + decision.deathId() + " " + ex); } } } + private boolean settled(EstateDecision decision) { + var last = reported.get(decision.deathId()); + return last != null && last.getKey() == decision.revision() && last.getValue() instanceof Outcome.Done; + } private static GearIdentity.Owner owner(CharacterRef ref) { return new GearIdentity.Owner(ref.ownerId(), ref.characterId()); } } diff --git a/src/test/java/net/tfminecraft/magic/gear/ownership/CharacterGearEstateTest.java b/src/test/java/net/tfminecraft/magic/gear/ownership/CharacterGearEstateTest.java index eb6a30d..9ad11b0 100644 --- a/src/test/java/net/tfminecraft/magic/gear/ownership/CharacterGearEstateTest.java +++ b/src/test/java/net/tfminecraft/magic/gear/ownership/CharacterGearEstateTest.java @@ -192,6 +192,29 @@ UUID station() throws IOException { verify(estates, times(1)).report(eq(death), eq("magic-gear"), eq(1L), any()); } + @Test void hourlyReconcileSkipsARevisionAlreadyReportedDoneButSettlesRetriesAndNewRevisions() throws Exception { + decision(Recipient.FROZEN, 1); + orbs.when(() -> GearOrbService.touches(any())).thenReturn(true); + participant.reconcile(); participant.reconcile(); + orbs.verify(() -> GearOrbService.touches(any()), times(2)); + verify(estates, times(1)).report(eq(death), eq("magic-gear"), eq(1L), isA(Outcome.Retry.class)); + orbs.when(() -> GearOrbService.touches(any())).thenReturn(false); + participant.reconcile(); participant.reconcile(); + orbs.verify(() -> GearOrbService.touches(any()), times(3)); + verify(estates, times(1)).report(eq(death), eq("magic-gear"), eq(1L), isA(Outcome.Done.class)); + decision(Recipient.FROZEN, 2); + participant.reconcile(); participant.reconcile(); + orbs.verify(() -> GearOrbService.touches(any()), times(4)); + verify(estates, times(1)).report(eq(death), eq("magic-gear"), eq(2L), isA(Outcome.Done.class)); + participant = new CharacterGearEstate(plugin); + when(characters.deathId(from.characterId())).thenReturn(Optional.of(UUID.randomUUID())); + participant.reconcile(); + orbs.verify(() -> GearOrbService.touches(any()), times(4)); + when(characters.deathId(from.characterId())).thenReturn(Optional.of(death)); + participant.reconcile(); + orbs.verify(() -> GearOrbService.touches(any()), times(5)); + } + @Test void registeredHourlyReconcileReportsOnlyChangedSuccessfulOutcomesAndSurvivesFailure() throws Exception { var value = decision(Recipient.FROZEN, 1); Server facade = mock(Server.class);