diff --git a/src/main/java/net/tfminecraft/simplefactions/government/stability/BankruptcyMemo.java b/src/main/java/net/tfminecraft/simplefactions/government/stability/BankruptcyMemo.java new file mode 100644 index 00000000..ad36cf19 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/government/stability/BankruptcyMemo.java @@ -0,0 +1,77 @@ +package net.tfminecraft.simplefactions.government.stability; + +import java.util.IdentityHashMap; +import java.util.Map; + +import org.bukkit.Bukkit; + +import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.ledger.Ledger; +import net.tfminecraft.simplefactions.guild.Guild; + +/** + * Whether each realm guild is bankrupt, read once per trade recalculation on the server thread. + * The flood asks for a province owner's stability once per foreign guild it reaches, and each ask + * reads the realm guild's treasury from the ledger. During a run on the server thread nothing + * else can post to a treasury or remove a bank, and nothing in a recalculation does, so every ask + * in that run gets the same answer. Everything else in stability, including unrest that decays + * over time, is still read live on every ask. + * + *

Runs off the server thread (async previews) read live as before: the server can change a + * guild's bank while they run. If the ledger is closed, every ask reads live too, because a + * closed ledger reads every balance as zero. + */ +public final class BankruptcyMemo { + private static final ThreadLocal> RUN = new ThreadLocal<>(); + + private BankruptcyMemo() {} + + /** What {@link #begin} replaced, so {@link #end} can put it back. */ + public static final class Run { + private final Map outer; + + private Run(Map outer) { + this.outer = outer; + } + } + + /** Starts a run on this thread. A nested run, such as a preview, gets its own answers. */ + public static Run begin() { + Run run = new Run(RUN.get()); + if (onServerThread()) RUN.set(new IdentityHashMap<>()); + else RUN.remove(); + return run; + } + + public static void end(Run run) { + if (run.outer == null) RUN.remove(); + else RUN.set(run.outer); + } + + /** {@link Guild#isBankrupt()}, read once per guild in a server-thread run and live otherwise. */ + public static boolean isBankrupt(Guild guild) { + Map run = RUN.get(); + if (run == null) return guild.isBankrupt(); + if (!ledgerOpen()) { + // A failed read closes the ledger for good, and answers from before it are stale. + run.clear(); + return guild.isBankrupt(); + } + Boolean bankrupt = run.get(guild); + if (bankrupt == null) { + bankrupt = guild.isBankrupt(); + // Keep only answers the ledger stayed open for; this very read may have closed it. + if (ledgerOpen()) run.put(guild, bankrupt); + } + return bankrupt; + } + + private static boolean onServerThread() { + return Bukkit.getServer() != null && Bukkit.isPrimaryThread(); + } + + private static boolean ledgerOpen() { + Ledger ledger = DenarEconomy.getLedger(); + return ledger != null && ledger.isOpen(); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/government/stability/StateStability.java b/src/main/java/net/tfminecraft/simplefactions/government/stability/StateStability.java index b44d3a3e..d474f26a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/stability/StateStability.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/stability/StateStability.java @@ -26,7 +26,7 @@ public static StabilityFacts facts(Faction faction) { facts.government = law(faction, "government", "autocracy"); facts.electedLeadership = "elected".equalsIgnoreCase(law(faction, "leadership", "fixed")); facts.provinces = faction.getProvinces() == null ? 0 : faction.getProvinces().size(); - facts.bankrupt = faction.getOrCreateMainGuild() != null && faction.getOrCreateMainGuild().isBankrupt(); + facts.bankrupt = faction.getOrCreateMainGuild() != null && BankruptcyMemo.isBankrupt(faction.getOrCreateMainGuild()); if (faction.getGovernment() != null && faction.getGovernment().getStabilityModifiers() != null) { faction.getGovernment().getStabilityModifiers().forEach(modifier -> { if (modifier != null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index 58f8f231..2757740d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -642,9 +642,8 @@ public static Guild getGuildByLeader(String leader) { public static Guild getGuildByString(String id) { for(Faction f : factions) { - for(Guild guild : f.getGuildHandler().getGuilds()) { - if(guild.getId().equalsIgnoreCase(id)) return guild; - } + Guild guild = f.getGuildHandler().findGuild(id); + if(guild != null) return guild; } return null; } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java index 254b9e5b..5a580c5e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java @@ -33,6 +33,7 @@ import net.tfminecraft.simplefactions.war.resolution.PillageTradeHit; import net.tfminecraft.simplefactions.enums.GuildModifier; import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.government.stability.BankruptcyMemo; import net.tfminecraft.simplefactions.objects.handler.TaxHandler; import net.tfminecraft.simplefactions.laws.Law; import net.tfminecraft.simplefactions.laws.LawGroup; @@ -116,6 +117,7 @@ public void recalculate() { return; } InstallationAccess.beginRecalculation(); + BankruptcyMemo.Run memo = BankruptcyMemo.begin(); try { installationAccess = new HashMap<>(); TradeGraph.refreshIfLive(this); @@ -130,6 +132,7 @@ public void recalculate() { recalculateProsperity(); for(Guild guild : FactionManager.getAllGuilds()) getIncome(guild); } finally { + BankruptcyMemo.end(memo); InstallationAccess.endRecalculation(); } } @@ -145,6 +148,7 @@ public void recalculateQuiet(List guilds, List factions) { previewGuilds = guilds == null ? List.of() : List.copyOf(guilds); previewFactions = factions == null ? List.of() : List.copyOf(factions); InstallationAccess.beginRecalculation(); + BankruptcyMemo.Run memo = BankruptcyMemo.begin(); try { installationAccess = new HashMap<>(); TradeGraph.refreshIfLive(this); @@ -161,6 +165,7 @@ public void recalculateQuiet(List guilds, List factions) { } recalculateProsperity(); } finally { + BankruptcyMemo.end(memo); InstallationAccess.endRecalculation(); } } @@ -176,6 +181,7 @@ public void recalculateForSingleGuild(Guild g, boolean save) { return; } InstallationAccess.beginRecalculation(); + BankruptcyMemo.Run memo = BankruptcyMemo.begin(); try { installationAccess = new HashMap<>(); TradeGraph.refreshIfLive(this); @@ -189,6 +195,7 @@ public void recalculateForSingleGuild(Guild g, boolean save) { } } } finally { + BankruptcyMemo.end(memo); InstallationAccess.endRecalculation(); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/handler/GuildHandler.java b/src/main/java/net/tfminecraft/simplefactions/objects/handler/GuildHandler.java index db3e7033..1b839266 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/handler/GuildHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/handler/GuildHandler.java @@ -6,6 +6,8 @@ import java.util.List; import java.util.Map; +import org.bukkit.Bukkit; + import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.managers.ProvinceManager; import net.tfminecraft.simplefactions.objects.Faction; @@ -23,6 +25,23 @@ public List getGuilds() { return new ArrayList<>(guilds.values()); } + /** + * The first guild in {@link #getGuilds()} order whose id matches, ignoring case. The server + * thread reads the map directly; async previews reach this too, so they read a copy, as + * before, and a guild created or removed meanwhile cannot break their iteration. + */ + public Guild findGuild(String id) { + Iterable each = onServerThread() ? guilds.values() : getGuilds(); + for (Guild guild : each) { + if (guild.getId().equalsIgnoreCase(id)) return guild; + } + return null; + } + + private static boolean onServerThread() { + return Bukkit.getServer() != null && Bukkit.isPrimaryThread(); + } + public List getReleasableGuilds() { List releasable = new ArrayList<>(); for(Guild g : guilds.values()) { diff --git a/src/test/java/net/tfminecraft/simplefactions/government/stability/BankruptcyMemoTest.java b/src/test/java/net/tfminecraft/simplefactions/government/stability/BankruptcyMemoTest.java new file mode 100644 index 00000000..c562503f --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/government/stability/BankruptcyMemoTest.java @@ -0,0 +1,231 @@ +package net.tfminecraft.simplefactions.government.stability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.ledger.Ledger; +import net.tfminecraft.simplefactions.government.Government; +import net.tfminecraft.simplefactions.government.StabilityModifier; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.objects.Faction; + +class BankruptcyMemoTest { + private MockedStatic bukkit; + private Ledger previousLedger; + private final AtomicBoolean serverThread = new AtomicBoolean(true); + private final AtomicBoolean ledgerOpen = new AtomicBoolean(true); + + @BeforeEach + void setUp() { + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenAnswer(call -> serverThread.get()); + previousLedger = DenarEconomy.getLedger(); + Ledger ledger = mock(Ledger.class); + when(ledger.isOpen()).thenAnswer(call -> ledgerOpen.get()); + DenarEconomy.setLedger(ledger); + } + + @AfterEach + void tearDown() { + DenarEconomy.setLedger(previousLedger); + bukkit.close(); + } + + @Test + void aServerRunReadsEachTreasuryOnceAndANestedRunReadsItsOwn() { + Guild realm = mock(Guild.class); + AtomicBoolean bankrupt = new AtomicBoolean(true); + when(realm.isBankrupt()).thenAnswer(call -> bankrupt.get()); + BankruptcyMemo.Run outer = BankruptcyMemo.begin(); + try { + assertTrue(BankruptcyMemo.isBankrupt(realm)); + bankrupt.set(false); + assertTrue(BankruptcyMemo.isBankrupt(realm)); + BankruptcyMemo.Run inner = BankruptcyMemo.begin(); + try { + assertFalse(BankruptcyMemo.isBankrupt(realm)); + bankrupt.set(true); + assertFalse(BankruptcyMemo.isBankrupt(realm)); + } finally { + BankruptcyMemo.end(inner); + } + assertTrue(BankruptcyMemo.isBankrupt(realm)); + verify(realm, times(2)).isBankrupt(); + } finally { + BankruptcyMemo.end(outer); + } + bankrupt.set(false); + assertFalse(BankruptcyMemo.isBankrupt(realm)); + bankrupt.set(true); + assertTrue(BankruptcyMemo.isBankrupt(realm)); + verify(realm, times(4)).isBankrupt(); + } + + @Test + void anAsyncRunReadsLiveSoABankRemovedMeanwhileIsSeen() { + serverThread.set(false); + Guild realm = mock(Guild.class); + AtomicBoolean hasBank = new AtomicBoolean(true); + // isBankrupt() is false once the bank is gone, as Guild.isBankrupt() reads it. + when(realm.isBankrupt()).thenAnswer(call -> hasBank.get()); + BankruptcyMemo.Run run = BankruptcyMemo.begin(); + try { + assertTrue(BankruptcyMemo.isBankrupt(realm)); + // The server thread runs /f delbank while the preview is still flooding. + hasBank.set(false); + assertFalse(BankruptcyMemo.isBankrupt(realm)); + } finally { + BankruptcyMemo.end(run); + } + verify(realm, times(2)).isBankrupt(); + } + + @Test + void anAsyncRunInsideAServerRunStillReadsLiveAndTheServerRunResumes() { + Guild realm = mock(Guild.class); + AtomicBoolean bankrupt = new AtomicBoolean(true); + when(realm.isBankrupt()).thenAnswer(call -> bankrupt.get()); + BankruptcyMemo.Run outer = BankruptcyMemo.begin(); + try { + assertTrue(BankruptcyMemo.isBankrupt(realm)); + serverThread.set(false); + BankruptcyMemo.Run inner = BankruptcyMemo.begin(); + try { + bankrupt.set(false); + assertFalse(BankruptcyMemo.isBankrupt(realm)); + } finally { + BankruptcyMemo.end(inner); + } + assertTrue(BankruptcyMemo.isBankrupt(realm)); + } finally { + BankruptcyMemo.end(outer); + } + } + + @Test + void aClosedLedgerDropsTheRunsAnswersAndEveryAskReadsLive() { + Guild first = mock(Guild.class); + Guild second = mock(Guild.class); + AtomicBoolean firstBankrupt = new AtomicBoolean(true); + when(first.isBankrupt()).thenAnswer(call -> firstBankrupt.get()); + // Reading the second treasury fails, and the ledger closes for good. + when(second.isBankrupt()).thenAnswer(call -> { + ledgerOpen.set(false); + return false; + }); + BankruptcyMemo.Run run = BankruptcyMemo.begin(); + try { + assertTrue(BankruptcyMemo.isBankrupt(first)); + assertFalse(BankruptcyMemo.isBankrupt(second)); + // A closed ledger reads every balance as zero, so the first realm is no longer bankrupt. + firstBankrupt.set(false); + assertFalse(BankruptcyMemo.isBankrupt(first)); + assertFalse(BankruptcyMemo.isBankrupt(second)); + // Answers from before the close are gone even if the ledger came back. + ledgerOpen.set(true); + firstBankrupt.set(true); + assertTrue(BankruptcyMemo.isBankrupt(first)); + firstBankrupt.set(false); + assertTrue(BankruptcyMemo.isBankrupt(first)); + } finally { + BankruptcyMemo.end(run); + } + verify(first, times(3)).isBankrupt(); + verify(second, times(2)).isBankrupt(); + } + + @Test + void noLedgerAtAllReadsLive() { + DenarEconomy.setLedger(null); + Guild realm = mock(Guild.class); + when(realm.isBankrupt()).thenReturn(true); + BankruptcyMemo.Run run = BankruptcyMemo.begin(); + try { + assertTrue(BankruptcyMemo.isBankrupt(realm)); + assertTrue(BankruptcyMemo.isBankrupt(realm)); + } finally { + BankruptcyMemo.end(run); + } + verify(realm, times(2)).isBankrupt(); + } + + @Test + void noServerAtAllReadsLive() { + bukkit.when(Bukkit::getServer).thenReturn(null); + Guild realm = mock(Guild.class); + when(realm.isBankrupt()).thenReturn(true); + BankruptcyMemo.Run run = BankruptcyMemo.begin(); + try { + assertTrue(BankruptcyMemo.isBankrupt(realm)); + assertTrue(BankruptcyMemo.isBankrupt(realm)); + } finally { + BankruptcyMemo.end(run); + } + verify(realm, times(2)).isBankrupt(); + } + + @Test + void unrestThatDecaysDuringARunStillChangesStabilityBetweenProvinces() { + long[] now = {0}; + Guild realm = mock(Guild.class); + Faction faction = realmDecaying(realm, now); + + // Unmemoised: what each province would have seen before. + StabilityReport liveEarly = StateStability.of(faction); + now[0] = 1; + StabilityReport liveLate = StateStability.of(faction); + assertNotEquals(liveEarly.stability, liveLate.stability); + + now[0] = 0; + BankruptcyMemo.Run run = BankruptcyMemo.begin(); + try { + StabilityReport early = StateStability.of(faction); + now[0] = 1; + StabilityReport late = StateStability.of(faction); + assertEquals(liveEarly.stability, early.stability); + assertEquals(liveLate.stability, late.stability); + assertEquals(liveEarly.status, early.status); + assertEquals(liveLate.status, late.status); + } finally { + BankruptcyMemo.end(run); + } + // Two live asks read the treasury twice; the run read it once for both of its asks. + verify(realm, times(3)).isBankrupt(); + } + + /** A realm whose office unrest wears off as {@code now} advances. */ + private static Faction realmDecaying(Guild realm, long[] now) { + Faction faction = mock(Faction.class); + Government government = mock(Government.class); + when(faction.getId()).thenReturn("decaying"); + when(faction.getProvinces()).thenReturn(new ArrayList<>(List.of(1))); + when(faction.getRelations()).thenReturn(new HashMap<>()); + when(faction.getOrCreateMainGuild()).thenReturn(realm); + when(faction.getGovernment()).thenReturn(government); + when(government.getStabilityModifiers()) + .thenAnswer(call -> List.of(new StabilityModifier("Office unrest", -50 + 7.5 * now[0], 0))); + when(realm.isBankrupt()).thenReturn(false); + return faction; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/MercenarySettlementTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/MercenarySettlementTest.java index 98796d77..719b1da0 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/income/MercenarySettlementTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/MercenarySettlementTest.java @@ -302,6 +302,10 @@ private static final class Party { when(faction.getOrCreateMainGuild()).thenReturn(guild); GuildHandler handler = mock(GuildHandler.class); when(handler.getGuilds()).thenReturn(new ArrayList<>(List.of(guild))); + when(handler.findGuild(org.mockito.ArgumentMatchers.any())).thenAnswer(call -> { + String id = call.getArgument(0); + return guildId.equalsIgnoreCase(id) ? guild : null; + }); when(faction.getGuildHandler()).thenReturn(handler); when(guild.getId()).thenReturn(guildId); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java index 0d18678d..287a2273 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java @@ -133,6 +133,8 @@ private Faction faction(String id, String leader) { when(faction.getProvinces()).thenReturn(new ArrayList<>()); when(faction.getTitles()).thenReturn(new ArrayList<>()); when(faction.getGuildHandler().getGuilds()).thenReturn(new ArrayList<>()); + when(faction.getGuildHandler().findGuild(any())) + .thenAnswer(call -> firstGuild(faction.getGuildHandler().getGuilds(), call.getArgument(0))); when(faction.getGovernment().getFaction()).thenReturn(faction); when(faction.getGovernment().getMovementById(anyString())).thenReturn(null); when(faction.getGovernment().getMovementByLeader(anyString())).thenReturn(null); @@ -149,6 +151,14 @@ private Faction faction(String id, String leader) { return faction; } + /** The real {@code GuildHandler.findGuild} scan over the stubbed guild list. */ + private static Guild firstGuild(List guilds, String id) { + for (Guild guild : guilds) { + if (guild.getId().equalsIgnoreCase(id)) return guild; + } + return null; + } + private Guild guild(Faction faction, String id, String leader) { Guild guild = mock(Guild.class, RETURNS_DEEP_STUBS); when(guild.getId()).thenReturn(id); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GuildMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GuildMenusCoverageTest.java index 49253dd0..07c0b06a 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GuildMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GuildMenusCoverageTest.java @@ -880,6 +880,9 @@ void exactGuildRenderingUsesRealBranchControlsAndCurrentMembership() { () -> BranchIncomePreviewService.schedule( fixture.player, inventory, 38, prepared, display, display.getBranch(0), -1)); + // A guild that still earns no trade recalculates on every open, however soon it reopens. + view.guildView(fixture.player, display, inventory); + verify(fixture.provinces, times(2)).recalculate(); display.setLeader("Other"); display.kick("Leader"); display.setCapital(-1); diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/handler/GuildHandlerFindGuildTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/handler/GuildHandlerFindGuildTest.java new file mode 100644 index 00000000..9f6675d7 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/objects/handler/GuildHandlerFindGuildTest.java @@ -0,0 +1,78 @@ +package net.tfminecraft.simplefactions.objects.handler; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.objects.Faction; + +class GuildHandlerFindGuildTest { + + @Test + void findGuildMatchesTheFirstListedIdIgnoringCaseOnTheServerThread() { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + GuildHandler handler = new GuildHandler(mock(Faction.class)); + Guild merchants = guild("merchants"); + Guild smiths = guild("Smiths"); + handler.addGuild(merchants); + handler.addGuild(smiths); + Guild expected = null; + for (Guild guild : handler.getGuilds()) { + if (guild.getId().equalsIgnoreCase("SMITHS")) { + expected = guild; + break; + } + } + assertSame(smiths, expected); + assertSame(expected, handler.findGuild("SMITHS")); + assertSame(merchants, handler.findGuild("merchants")); + assertNull(handler.findGuild("bakers")); + assertNull(handler.findGuild(null)); + } + } + + @Test + void anAsyncLookupReadsACopySoAGuildAddedMeanwhileCannotBreakIt() { + GuildHandler handler = new GuildHandler(mock(Faction.class)); + Guild merchants = guild("merchants"); + handler.addGuild(merchants); + Guild smiths = guild("smiths"); + handler.addGuild(smiths); + AtomicBoolean added = new AtomicBoolean(); + // The server thread founds a guild while the preview is still walking the realm. + for (Guild guild : new Guild[] {merchants, smiths}) { + String id = guild.getId(); + when(guild.getId()).thenAnswer(call -> { + if (added.compareAndSet(false, true)) handler.addGuild(guild("bakers")); + return id; + }); + } + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + assertNull(handler.findGuild("nobody")); + } + // No server at all (unit tests, shutdown) reads a copy too. + assertSame(merchants, handler.findGuild("MERCHANTS")); + assertSame(handler.getGuilds().stream().filter(g -> g.getId().equals("bakers")).findFirst().orElseThrow(), + handler.findGuild("bakers")); + } + + private static Guild guild(String id) { + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn(id); + return guild; + } +}