From b91cdefb552c38832414eae919cf03f132b49792 Mon Sep 17 00:00:00 2001 From: XxFran10xX Date: Thu, 29 Oct 2026 09:48:15 +0100 Subject: [PATCH 1/5] fix: keep node value on the guild and realm that own the node Node benefits sit on each realm's own guild, and only node events resynced them. Two things went stale after a realm dissolved or a guild was elevated: - A realm guild that became an ordinary guild (its realm dissolved into an overlord) kept its last "Nodes" wealth for good. Every sync now removes it from guilds that are not a realm's own guild. - A node whose guild moved to another realm kept counting for the old realm until some node event. A 30 s check now resyncs when any active node's guild or realm changed since the last sync. Co-Authored-By: Claude Opus 5.5 --- .../dowsing/managers/NodeManager.java | 56 +++++++++++++ .../managers/NodeManagerCyclesTest.java | 80 ++++++++++++++++++- 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java index 1caebec..951ea66 100644 --- a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java +++ b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java @@ -69,8 +69,42 @@ public static void requestNodeBenefitSync() { syncFactionNodeBenefits(); } + /** Each active node's guild and that guild's realm at the last sync, to spot realm changes. */ + private static List lastOwners = List.of(); + + /** + * Node benefits sit on each realm's own guild and only node events resync them. A dissolve, + * elevation or relocation moves a node to another realm without one, so the minute cycle + * resyncs when any active node's guild or realm changed since the last sync. + */ + public static void syncIfRealmsChanged() { + if (!SimpleFactionsApi.ready()) return; + if (!sameOwners(owners(), lastOwners)) syncFactionNodeBenefits(); + } + + private static List owners() { + List owners = new ArrayList<>(); + for(Node n : nodes) { + if(!n.hasGuild() || !Boolean.TRUE.equals(n.getIsActive())) continue; + Guild g = n.getGuild(); + owners.add(n); + owners.add(g); + owners.add(g == null ? null : g.getFaction()); + } + return owners; + } + + private static boolean sameOwners(List now, List before) { + if (now.size() != before.size()) return false; + for (int i = 0; i < now.size(); i++) { + if (now.get(i) != before.get(i)) return false; + } + return true; + } + public static void syncFactionNodeBenefits() { if (!SimpleFactionsApi.ready()) return; + lastOwners = owners(); Map prestigeByFaction = new HashMap<>(); Map wealthByFaction = new HashMap<>(); for(Node n : nodes) { @@ -91,6 +125,12 @@ public static void syncFactionNodeBenefits() { mainGuild.addWealthModifier(new Modifier("Nodes", wealth, true)); mainGuild.updateWealth(); } + // A realm's own guild that became an ordinary guild (its realm dissolved into an overlord) + // kept its last node value; only realm guilds carry one. + for(Guild g : FactionManager.getAllGuilds()) { + if(g == null || g.isBase()) continue; + if(g.getWealthModifiers().removeIf(m -> "Nodes".equalsIgnoreCase(m.getType()))) g.updateWealth(); + } } public static int getExtraCapacity(Guild g) { @@ -191,6 +231,7 @@ public String getClickedFurniture(Block b) { public void start() { particleCycle(); hourCycle(); + realmCycle(); new BukkitRunnable() { public void run() @@ -239,6 +280,21 @@ public void run() } }.runTaskTimer(DowsingMain.plugin, 0L, 1200L); } + /** Every 30 s: resync node benefits when a node's guild or realm changed without a node event. */ + public void realmCycle(){ + new BukkitRunnable() + { + public void run() + { + if (!DowsingMain.plugin.nodesReady()) return; + try { + syncIfRealmsChanged(); + } catch(RuntimeException ex) { + DowsingMain.plugin.getLogger().log(java.util.logging.Level.SEVERE, "Failed to check node realms", ex); + } + } + }.runTaskTimer(DowsingMain.plugin, 0L, 600L); + } public void particleCycle(){ new BukkitRunnable() { diff --git a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java index 3a64c60..ea8ca18 100644 --- a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java +++ b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java @@ -70,6 +70,63 @@ void factionBenefitsAggregateOnlyActiveOwnedNodesAndClearStaleBenefits() { verify(emptyMain).addWealthModifier(modifier.capture()); assertEquals(0.0, modifier.getValue().getAmount()); verify(emptyMain).updateWealth(); } + @Test + void syncDropsANodeValueLeftOnAGuildThatIsNoLongerItsRealmsOwn() { + Guild formerRealm = mock(Guild.class), ordinary = mock(Guild.class), realm = mock(Guild.class); + List stale = new ArrayList<>(List.of(new Modifier("Bank", 5.0, false), new Modifier("Nodes", 40.0, true))); + when(formerRealm.getWealthModifiers()).thenReturn(stale); + when(ordinary.getWealthModifiers()).thenReturn(new ArrayList<>(List.of(new Modifier("Bank", 1.0, false)))); + when(realm.isBase()).thenReturn(true); + f.factions.when(FactionManager::getAllGuilds).thenReturn(new ArrayList<>(java.util.Arrays.asList(formerRealm, ordinary, realm, null))); + NodeManager.syncFactionNodeBenefits(); + assertEquals(List.of("Bank"), stale.stream().map(Modifier::getType).toList()); + verify(formerRealm).updateWealth(); + verify(ordinary, never()).updateWealth(); + verify(realm, never()).getWealthModifiers(); + } + + @Test + void theMinuteCheckResyncsOnlyWhenANodesGuildOrRealmChanged() { + Faction first = mock(Faction.class), second = mock(Faction.class); + Guild firstMain = mock(Guild.class), secondMain = mock(Guild.class); + when(first.getOrCreateMainGuild()).thenReturn(firstMain); when(second.getOrCreateMainGuild()).thenReturn(secondMain); + f.factions.when(FactionManager::getCopy).thenReturn(new ArrayList<>(List.of(first, second))); + when(f.guild.getFaction()).thenReturn(first); + when(f.node.getIsActive()).thenReturn(true); when(f.node.getWealthModifier()).thenReturn(10.0); + Node idle = f.nodeAt(f.location); + NodeManager.nodes.addAll(List.of(f.node, idle)); + NodeManager.syncFactionNodeBenefits(); + verify(first, times(1)).updatePrestige(); + NodeManager.syncIfRealmsChanged(); + verify(first, times(1)).updatePrestige(); + // The guild's realm dissolved into another one: the node's value has to follow it. + when(f.guild.getFaction()).thenReturn(second); + NodeManager.syncIfRealmsChanged(); + verify(first, times(2)).updatePrestige(); + ArgumentCaptor modifier = ArgumentCaptor.forClass(Modifier.class); + verify(secondMain, times(2)).addWealthModifier(modifier.capture()); + assertEquals(10.0, modifier.getValue().getAmount()); + NodeManager.syncIfRealmsChanged(); + verify(first, times(2)).updatePrestige(); + Guild other = mock(Guild.class); when(other.getFaction()).thenReturn(second); + when(f.node.getGuild()).thenReturn(other); + NodeManager.syncIfRealmsChanged(); + verify(first, times(3)).updatePrestige(); + when(f.node.getGuild()).thenReturn(null); + NodeManager.syncIfRealmsChanged(); + verify(first, times(4)).updatePrestige(); + NodeManager.nodes.remove(idle); + NodeManager.syncIfRealmsChanged(); + verify(first, times(4)).updatePrestige(); + NodeManager.nodes.clear(); + NodeManager.syncIfRealmsChanged(); + verify(first, times(5)).updatePrestige(); + net.tfminecraft.dowsing.testsupport.FactionFixture.api.when(net.tfminecraft.simplefactions.api.SimpleFactionsApi::ready).thenReturn(false); + NodeManager.nodes.add(f.node); + NodeManager.syncIfRealmsChanged(); + verify(first, times(5)).updatePrestige(); + } + @Test void countsCapacityAndUpkeepRespectOwnershipSpecialNodesAndPurchasedLimits() { assertEquals(0, NodeManager.getExtraCapacity(null)); assertFalse(NodeManager.canPurchaseCapacity(null)); @@ -211,7 +268,7 @@ void timersUseTheirIntendedCadenceAndRunParticlesChecksAndEfficiencyGrowth() { Node claimed = f.node, unclaimed = f.nodeAt(f.location); when(unclaimed.isClaimable()).thenReturn(true); NodeManager.nodes.addAll(List.of(claimed, unclaimed)); try (Cycles cycles = new Cycles()) { - f.manager.start(); assertEquals(java.util.Set.of(5L, 1200L, 72000L), cycles.tasks.keySet()); + f.manager.start(); assertEquals(java.util.Set.of(5L, 600L, 1200L, 72000L), cycles.tasks.keySet()); cycles.tasks.get(5L).run(); verify(claimed).check(); verify(f.world).spawnParticle(Particle.HAPPY_VILLAGER, new Location(f.world, 2.5, 65, 3.5), 10); cycles.tasks.get(72000L).run(); verify(claimed).growEfficiency(); verify(unclaimed).growEfficiency(); @@ -234,6 +291,27 @@ void productionCycleCompletesRealNodeOutputThenConsumesTheNextInputBatch() { } } + @Test + void theRealmCycleResyncsWhenReadyAndLogsAFailureInsteadOfStopping() { + Faction realm = mock(Faction.class); Guild main = mock(Guild.class); + when(realm.getOrCreateMainGuild()).thenReturn(main); + f.factions.when(FactionManager::getCopy).thenReturn(new ArrayList<>(List.of(realm))); + when(f.guild.getFaction()).thenReturn(realm); when(f.node.getIsActive()).thenReturn(true); + NodeManager.nodes.add(f.node); + try (Cycles cycles = new Cycles()) { + f.manager.start(); + when(DowsingMain.plugin.nodesReady()).thenReturn(false); + cycles.tasks.get(600L).run(); + verify(realm, never()).updatePrestige(); + when(DowsingMain.plugin.nodesReady()).thenReturn(true); + cycles.tasks.get(600L).run(); + verify(realm).updatePrestige(); + when(f.node.getIsActive()).thenThrow(new IllegalStateException("broken node")); + cycles.tasks.get(600L).run(); + verify(f.logger).log(eq(java.util.logging.Level.SEVERE), eq("Failed to check node realms"), any(IllegalStateException.class)); + } + } + @Test void failedOutputKeepsTheCyclePendingAndExceptionsDoNotStopOtherNodes() { Node inactive = f.nodeAt(f.location), unowned = f.nodeAt(f.location), broken = f.nodeAt(f.location), pending = f.node; From 407b822e721693b6955286f9df19909c49f1f72a Mon Sep 17 00:00:00 2001 From: XxFran10xX Date: Thu, 29 Oct 2026 09:50:06 +0100 Subject: [PATCH 2/5] fix: clear stale node value by realm guild identity, not guild type Self-review: a realm's own guild is the one whose id is the realm's, so the cleanup now skips exactly the main guilds the sync just wrote to. Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/dowsing/managers/NodeManager.java | 7 ++++++- .../dowsing/managers/NodeManagerCyclesTest.java | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java index 951ea66..5ead77d 100644 --- a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java +++ b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java @@ -2,9 +2,12 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.TreeMap; import org.apache.commons.lang.WordUtils; @@ -116,6 +119,7 @@ public static void syncFactionNodeBenefits() { prestigeByFaction.merge(f, n.getPrestigeGain(), Double::sum); wealthByFaction.merge(f, n.getWealthModifier(), Double::sum); } + Set realmGuilds = Collections.newSetFromMap(new IdentityHashMap<>()); for(Faction f : FactionManager.getCopy()) { double prestige = prestigeByFaction.getOrDefault(f, 0.0); double wealth = wealthByFaction.getOrDefault(f, 0.0); @@ -124,11 +128,12 @@ public static void syncFactionNodeBenefits() { Guild mainGuild = f.getOrCreateMainGuild(); mainGuild.addWealthModifier(new Modifier("Nodes", wealth, true)); mainGuild.updateWealth(); + realmGuilds.add(mainGuild); } // A realm's own guild that became an ordinary guild (its realm dissolved into an overlord) // kept its last node value; only realm guilds carry one. for(Guild g : FactionManager.getAllGuilds()) { - if(g == null || g.isBase()) continue; + if(g == null || realmGuilds.contains(g)) continue; if(g.getWealthModifiers().removeIf(m -> "Nodes".equalsIgnoreCase(m.getType()))) g.updateWealth(); } } diff --git a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java index ea8ca18..00c6aa4 100644 --- a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java +++ b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java @@ -76,13 +76,16 @@ void syncDropsANodeValueLeftOnAGuildThatIsNoLongerItsRealmsOwn() { List stale = new ArrayList<>(List.of(new Modifier("Bank", 5.0, false), new Modifier("Nodes", 40.0, true))); when(formerRealm.getWealthModifiers()).thenReturn(stale); when(ordinary.getWealthModifiers()).thenReturn(new ArrayList<>(List.of(new Modifier("Bank", 1.0, false)))); - when(realm.isBase()).thenReturn(true); + Faction owner = mock(Faction.class); when(owner.getOrCreateMainGuild()).thenReturn(realm); + f.factions.when(FactionManager::getCopy).thenReturn(new ArrayList<>(List.of(owner))); + // Only the guild a realm names as its own keeps a value, whatever its type says. + when(formerRealm.isBase()).thenReturn(true); f.factions.when(FactionManager::getAllGuilds).thenReturn(new ArrayList<>(java.util.Arrays.asList(formerRealm, ordinary, realm, null))); NodeManager.syncFactionNodeBenefits(); assertEquals(List.of("Bank"), stale.stream().map(Modifier::getType).toList()); verify(formerRealm).updateWealth(); verify(ordinary, never()).updateWealth(); - verify(realm, never()).getWealthModifiers(); + verify(realm, never()).getWealthModifiers(); verify(realm).updateWealth(); } @Test From dde7214fa61cd408632ec82fc0950c8d048c0a07 Mon Sep 17 00:00:00 2001 From: XxFran10xX Date: Thu, 29 Oct 2026 10:09:51 +0100 Subject: [PATCH 3/5] fix: record node owners only after a sync finishes Self-review: a sync that threw part way had already recorded the new owners, so the realm check never retried it. Co-Authored-By: Claude Opus 5.5 --- .../dowsing/managers/NodeManager.java | 4 +++- .../managers/NodeManagerCyclesTest.java | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java index 5ead77d..a38a9da 100644 --- a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java +++ b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java @@ -107,7 +107,7 @@ private static boolean sameOwners(List now, List before) { public static void syncFactionNodeBenefits() { if (!SimpleFactionsApi.ready()) return; - lastOwners = owners(); + List owners = owners(); Map prestigeByFaction = new HashMap<>(); Map wealthByFaction = new HashMap<>(); for(Node n : nodes) { @@ -136,6 +136,8 @@ public static void syncFactionNodeBenefits() { if(g == null || realmGuilds.contains(g)) continue; if(g.getWealthModifiers().removeIf(m -> "Nodes".equalsIgnoreCase(m.getType()))) g.updateWealth(); } + // Only a finished sync counts, so the realm check retries one that failed part way. + lastOwners = owners; } public static int getExtraCapacity(Guild g) { diff --git a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java index 00c6aa4..929a8ac 100644 --- a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java +++ b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java @@ -294,6 +294,24 @@ void productionCycleCompletesRealNodeOutputThenConsumesTheNextInputBatch() { } } + @Test + void aSyncThatFailsPartWayIsRetriedByTheRealmCheck() { + Faction realm = mock(Faction.class); Guild main = mock(Guild.class); + when(realm.getOrCreateMainGuild()).thenReturn(main); + when(f.guild.getFaction()).thenReturn(realm); when(f.node.getIsActive()).thenReturn(true); + NodeManager.nodes.add(f.node); + NodeManager.syncFactionNodeBenefits(); + Faction moved = mock(Faction.class); when(f.guild.getFaction()).thenReturn(moved); + f.factions.when(FactionManager::getCopy).thenThrow(new IllegalStateException("half way")); + assertThrows(IllegalStateException.class, NodeManager::syncIfRealmsChanged); + f.factions.when(FactionManager::getCopy).thenReturn(new ArrayList<>(List.of(moved))); + Guild movedMain = mock(Guild.class); when(moved.getOrCreateMainGuild()).thenReturn(movedMain); + NodeManager.syncIfRealmsChanged(); + verify(moved).updatePrestige(); + NodeManager.syncIfRealmsChanged(); + verify(moved, times(1)).updatePrestige(); + } + @Test void theRealmCycleResyncsWhenReadyAndLogsAFailureInsteadOfStopping() { Faction realm = mock(Faction.class); Guild main = mock(Guild.class); From 4a413cbfe1803f2a96eb3448b04c3137a1112a70 Mon Sep 17 00:00:00 2001 From: XxFran10xX Date: Thu, 29 Oct 2026 10:10:07 +0100 Subject: [PATCH 4/5] docs: point the realm check comment at realmCycle Co-Authored-By: Claude Opus 5.5 --- src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java index a38a9da..00d1034 100644 --- a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java +++ b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java @@ -77,7 +77,7 @@ public static void requestNodeBenefitSync() { /** * Node benefits sit on each realm's own guild and only node events resync them. A dissolve, - * elevation or relocation moves a node to another realm without one, so the minute cycle + * elevation or relocation moves a node to another realm without one, so {@link #realmCycle} * resyncs when any active node's guild or realm changed since the last sync. */ public static void syncIfRealmsChanged() { From 1b672a1cc98e31baaa58546743434659962465d9 Mon Sep 17 00:00:00 2001 From: XxFran10xX Date: Thu, 29 Oct 2026 10:29:24 +0100 Subject: [PATCH 5/5] fix: retry a failed node benefit sync even when no owner changed CodeRabbit: a node event's sync that failed left the owner snapshot equal, so the realm check never retried it. A flag now records whether the last sync finished, and the 30 s check retries until one does. Co-Authored-By: Claude Opus 5.5 --- .../net/tfminecraft/dowsing/managers/NodeManager.java | 8 ++++++-- .../dowsing/managers/NodeManagerCyclesTest.java | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java index 00d1034..a1db0ff 100644 --- a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java +++ b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java @@ -74,15 +74,17 @@ public static void requestNodeBenefitSync() { /** Each active node's guild and that guild's realm at the last sync, to spot realm changes. */ private static List lastOwners = List.of(); + /** Whether the last sync finished; a failed one is retried even if no owner changed. */ + private static boolean synced = false; /** * Node benefits sit on each realm's own guild and only node events resync them. A dissolve, * elevation or relocation moves a node to another realm without one, so {@link #realmCycle} - * resyncs when any active node's guild or realm changed since the last sync. + * resyncs when any active node's guild or realm changed since the last sync, or that sync failed. */ public static void syncIfRealmsChanged() { if (!SimpleFactionsApi.ready()) return; - if (!sameOwners(owners(), lastOwners)) syncFactionNodeBenefits(); + if (!synced || !sameOwners(owners(), lastOwners)) syncFactionNodeBenefits(); } private static List owners() { @@ -107,6 +109,7 @@ private static boolean sameOwners(List now, List before) { public static void syncFactionNodeBenefits() { if (!SimpleFactionsApi.ready()) return; + synced = false; List owners = owners(); Map prestigeByFaction = new HashMap<>(); Map wealthByFaction = new HashMap<>(); @@ -138,6 +141,7 @@ public static void syncFactionNodeBenefits() { } // Only a finished sync counts, so the realm check retries one that failed part way. lastOwners = owners; + synced = true; } public static int getExtraCapacity(Guild g) { diff --git a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java index 929a8ac..79f0dc8 100644 --- a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java +++ b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java @@ -310,6 +310,14 @@ void aSyncThatFailsPartWayIsRetriedByTheRealmCheck() { verify(moved).updatePrestige(); NodeManager.syncIfRealmsChanged(); verify(moved, times(1)).updatePrestige(); + // A node event's sync that fails with the same owners is retried too. + f.factions.when(FactionManager::getCopy).thenThrow(new IllegalStateException("again")); + assertThrows(IllegalStateException.class, NodeManager::requestNodeBenefitSync); + f.factions.when(FactionManager::getCopy).thenReturn(new ArrayList<>(List.of(moved))); + NodeManager.syncIfRealmsChanged(); + verify(moved, times(2)).updatePrestige(); + NodeManager.syncIfRealmsChanged(); + verify(moved, times(2)).updatePrestige(); } @Test