diff --git a/src/nativeTest/java/games/dmg/fantasystick/AdminActionConfigurationCatalogNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/AdminActionConfigurationCatalogNativeTest.java index cab3382..bf9fe8e 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/AdminActionConfigurationCatalogNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/AdminActionConfigurationCatalogNativeTest.java @@ -40,7 +40,14 @@ class AdminActionConfigurationCatalogNativeTest { private static JsonObject values(JsonObject row) { return row.getAsJsonObject("data"); } private static String outcome(JsonObject row) { return values(row).get("outcome").getAsString(); } - private List actions() throws Exception { return AdminActionEntryNativeTest.rows(data); } + private List actions(FantasyStickLifecycleNativeTest.Fixture f) throws Exception { + return NativeTestAwait.readAudit(f, () -> AdminActionEntryNativeTest.rows(data)); + } + private List awaitAction(FantasyStickLifecycleNativeTest.Fixture f, int before) throws Exception { + return NativeTestAwait.auditUntil(f, () -> since(AdminActionEntryNativeTest.rows(data), before), + rows -> rows.size() >= 3 && "result".equals(values(rows.getLast()).get("stage").getAsString()) + && !"pending".equals(outcome(rows.getLast())), "terminal configuration action export"); + } private List domain(String suffix) throws Exception { return ReskillEventsNativeTest.records(data).stream() .filter(row -> row.get("type").getAsString().equals("games.dmg.minecraft.fantasystick." + suffix)).toList(); @@ -75,11 +82,12 @@ class AdminActionConfigurationCatalogNativeTest { var block = new AdminActionEntryNativeTest.Entry(fixture, "command_block", true); assertEquals(suggested, console.map.tabComplete(console.sender, "fantasystick:fstickadmin config ")); assertEquals(suggested, block.map.tabComplete(block.sender, "fstickadmin config ")); - assertTrue(actions().isEmpty(), "catalog and value completion must never be audited"); + assertTrue(actions(f).isEmpty(), "catalog and value completion must never be audited"); var inventory = fixture.actor.core.snapshot(); var configuration = Files.readAllBytes(data.resolve("config.yml")); var actors = List.of(player, console, block); var checks = new java.util.ArrayList(); + var allRows = new java.util.ArrayList(); for (int family = 0; family < FAMILIES.size(); family++) { for (var key : FAMILIES.get(family)) { // Every key is exercised; each family also has a non-player sender, without tripling all reads. @@ -90,18 +98,21 @@ class AdminActionConfigurationCatalogNativeTest { assertNotNull(valueSuggestions, "real current/default completion for " + key); var current = readValue(data, key); if (current == null && !valueSuggestions.isEmpty()) current = valueSuggestions.getFirst(); - var before = actions().size(); var domainBefore = domain("config.read").size(); + var before = actions(f).size(); + var domainBefore = NativeTestAwait.readAudit(f, () -> domain("config.read")).size(); var messages = entry.messages().size(); assertTrue(entry.map.dispatch(entry.sender, "fstickadmin config " + key), key); assertTrue(entry.messages().size() > messages, "real owner must respond to " + key); assertTrue(entry.messages().subList(messages, entry.messages().size()).stream() .anyMatch(message -> message.contains(key + "=")), "key-specific read, not generic usage: " + key); - FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); - var rows = since(actions(), before); + var rows = awaitAction(f, before); + allRows.addAll(rows); var expectedCurrent = current; - var capturedReads = since(domain("config.read"), domainBefore); - FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); - var stableCount = actions().size(); + var capturedReads = key.equals(FrostShardSettings.RANGE) + ? NativeTestAwait.auditUntil(f, () -> since(domain("config.read"), domainBefore), + reads -> !reads.isEmpty(), "authoritative Frost configuration read") + : NativeTestAwait.readAudit(f, () -> since(domain("config.read"), domainBefore)); + var stableCount = actions(f).size(); checks.add(() -> { sequence(rows, kind, "completed"); assertEquals("read_completed", values(rows.getLast()).get("reason").getAsString(), key); @@ -129,6 +140,8 @@ class AdminActionConfigurationCatalogNativeTest { }); } } + FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + assertEquals(allRows, actions(f), "no delayed duplicate or extra action after the complete catalog"); assertAll("actual completed configuration key reads", checks); } } @@ -166,6 +179,7 @@ class AdminActionConfigurationCatalogNativeTest { var suggestions = player.map.tabComplete(player.sender, "fstickadmin config "); var actors = List.of(player, console, block); var checks = new java.util.ArrayList(); + var allRows = new java.util.ArrayList(); for (int i = 0; i < WRITES.size(); i++) { var pair = WRITES.get(i); var key = pair[0]; var requested = pair[1]; assertTrue(suggestions.contains(key), "write must use an exposed active key: " + key); @@ -175,15 +189,19 @@ class AdminActionConfigurationCatalogNativeTest { var old = readValue(data, key); if (old == null) { assertFalse(values.isEmpty(), key); old = values.getFirst(); } assertNotEquals(old, requested, "write must actually change effective value: " + key); - var before = actions().size(); var messages = entry.messages().size(); + var before = actions(f).size(); var messages = entry.messages().size(); assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin config " + key + " " + requested)); awaitWrite(f, entry, key, requested, messages); assertEquals(requested, readValue(data, key), "actual durable save: " + key); assertTrue(entry.map.tabComplete(entry.sender, "fstickadmin config " + key + " ").contains(requested), "native effective setting must change, not just the file: " + key); assertTrue(entry.messages().size() > messages, "native owner feedback: " + key); - FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); - var rows = since(actions(), before); + var rows = awaitAction(f, before); + allRows.addAll(rows); + if (key.equals(FrostShardSettings.RANGE)) + NativeTestAwait.auditUntil(f, () -> domain("config.changed"), + changes -> changes.stream().anyMatch(row -> values(row).get("key").getAsString().equals(key)), + "authoritative Frost configuration write"); var expectedBefore = old; checks.add(() -> { sequence(rows, kind, "completed"); @@ -197,7 +215,7 @@ class AdminActionConfigurationCatalogNativeTest { assertEquals(requested, result.get("after").getAsString(), key); assertEquals("untouched", readValue(data, "retained")); if (key.equals(FrostShardSettings.RANGE)) { - var changed = domain("config.changed").stream() + var changed = NativeTestAwait.readAudit(f, () -> domain("config.changed")).stream() .filter(row -> values(row).get("key").getAsString().equals(key)).toList(); assertEquals(1, changed.size(), "no replacement or duplicate authoritative Frost event"); assertEquals(values(changed.getFirst()).get("action_id").getAsString(), @@ -205,6 +223,8 @@ class AdminActionConfigurationCatalogNativeTest { } }); } + FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + assertEquals(allRows, actions(f), "no delayed duplicate or extra action after all writes"); assertAll("actual acknowledged configuration owner writes", checks); } } @@ -272,17 +292,17 @@ class AdminActionConfigurationCatalogNativeTest { @Test void unknownKeyAndMalformedTypedValuesRefuseWithoutSavingOrLeakingFreeformInput() throws Exception { seed(); try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true); for (var command : List.of("config unregistered-password SECRET_VALUE", "config " + FrostShardSettings.RANGE + " https://secret.invalid/token", "config " + RoleplaySettings.KEY + " SECRET_VALUE")) { var config = Files.readAllBytes(data.resolve("config.yml")); - var inventory = fixture.actor.core.snapshot(); var before = actions().size(); + var inventory = fixture.actor.core.snapshot(); var before = actions(f).size(); var messages = entry.messages().size(); assertTrue(entry.map.dispatch(entry.sender, "fstickadmin " + command)); assertTrue(entry.messages().size() > messages, "native validation must run"); - FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f); - var rows = since(actions(), before); + var rows = awaitAction(f, before); sequence(rows, "player", "refused"); assertFalse(rows.toString().contains("SECRET_VALUE")); assertFalse(rows.toString().contains("secret.invalid")); diff --git a/src/nativeTest/java/games/dmg/fantasystick/AdminActionIdentityAndEquipmentNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/AdminActionIdentityAndEquipmentNativeTest.java index f93232f..81c4c98 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/AdminActionIdentityAndEquipmentNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/AdminActionIdentityAndEquipmentNativeTest.java @@ -451,10 +451,7 @@ class AdminActionIdentityAndEquipmentNativeTest { } private static T observeExport(FantasyStickLifecycleNativeTest.Fixture f, java.util.concurrent.Callable read, long remaining) throws Exception { - var writer = (java.util.concurrent.ThreadPoolExecutor) FantasyStickLifecycleNativeTest.Fixture.field( - FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"), "worker"); - try { return writer.submit(read).get(remaining, TimeUnit.NANOSECONDS); } - catch (java.util.concurrent.ExecutionException error) { throw new AssertionError(error.getCause()); } + return NativeTestAwait.readAudit(f, read, remaining); } private static void awaitExport(FantasyStickLifecycleNativeTest.Fixture f, java.util.concurrent.Callable observed) throws Exception { diff --git a/src/nativeTest/java/games/dmg/fantasystick/AdventureParticipationModeNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/AdventureParticipationModeNativeTest.java index 60d15b9..9945aad 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/AdventureParticipationModeNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/AdventureParticipationModeNativeTest.java @@ -39,7 +39,8 @@ final class AdventureParticipationModeNativeTest { "adventureQuests"); f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "place", "camp", "--mode", "individual-only", "10", "Solo Camp"}); - await(f, () -> !adventures.snapshot().sites().isEmpty()); + await(f, () -> !adventures.snapshot().sites().isEmpty() + && admin.messages.stream().anyMatch(message -> message.contains("mode=individual-only"))); var site = adventures.snapshot().sites().getFirst(); assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY, site.participationMode()); assertTrue(admin.messages.getLast().contains("mode=individual-only")); @@ -54,7 +55,8 @@ final class AdventureParticipationModeNativeTest { assertTrue(admin.messages.getLast().contains("individual-only")); f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "mode", site.id().toString(), "party-required"}); - drain(f); + await(f, () -> adventures.findSite(site.id()).orElseThrow().participationMode() + == AdventureQuestState.ParticipationMode.PARTY_REQUIRED); assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED, adventures.findSite(site.id()).orElseThrow().participationMode()); int count = adventures.snapshot().sites().size(); @@ -74,11 +76,14 @@ final class AdventureParticipationModeNativeTest { new String[]{"dungeon", "mode", ""})); f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "mode", dungeon.id().toString(), "party-available"}); - drain(f); + await(f, () -> dungeons.find(dungeon.id()).orElseThrow().participationMode() + == AdventureQuestState.ParticipationMode.PARTY_AVAILABLE); assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE, dungeons.find(dungeon.id()).orElseThrow().participationMode()); f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "mode", - dungeon.id().toString(), "party-required"}); drain(f); + dungeon.id().toString(), "party-required"}); + await(f, () -> dungeons.find(dungeon.id()).orElseThrow().participationMode() + == AdventureQuestState.ParticipationMode.PARTY_REQUIRED); var room = dungeons.addRadiusRoom(dungeon.id(), "Boss", 0, 64, 0, 2, 3) .get(5, TimeUnit.SECONDS); dungeons.setBoss(dungeon.id(), room.id(), org.bukkit.entity.EntityType.HUSK, "Keeper", @@ -86,9 +91,11 @@ final class AdventureParticipationModeNativeTest { dungeons.setCompletionReward(dungeon.id(), java.math.BigInteger.ZERO, java.math.BigInteger.ZERO) .get(5, TimeUnit.SECONDS); f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "enable", - dungeon.id().toString()}); drain(f); + dungeon.id().toString()}); + await(f, () -> dungeons.find(dungeon.id()).orElseThrow().enabled()); f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "open", - dungeon.id().toString()}); drain(f); + dungeon.id().toString()}); + await(f, () -> adventures.findSite(dungeon.id()).isPresent() && adventures.offerOpen(dungeon.id())); assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED, adventures.findSite(dungeon.id()).orElseThrow().participationMode()); } @@ -120,8 +127,7 @@ final class AdventureParticipationModeNativeTest { var controller = (AdventureQuestController) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureQuestController"); - controller.open(member.player); drain(f); - var memberMenu = member.inventories.getLast(); + var memberMenu = open(f, controller, member); var personalItem = java.util.stream.IntStream.range(0, memberMenu.getSize()) .mapToObj(memberMenu::getItem).filter(java.util.Objects::nonNull) .filter(item -> PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName()) @@ -134,16 +140,19 @@ final class AdventureParticipationModeNativeTest { when(personalClick.getWhoClicked()).thenReturn(member.player); when(personalClick.getInventory()).thenReturn(memberMenu); when(personalClick.getCurrentItem()).thenReturn(personalItem); - controller.click(personalClick); drain(f); + int personalFeedback = member.messages.size(); + controller.click(personalClick); + accepted(f, member, personalFeedback); var personalQuest = adventures.activeFor(member.core.record.id(), party.id()).orElseThrow(); assertEquals(AdventureQuestState.UnitType.SOLO, personalQuest.unitType()); assertEquals(Set.of(member.core.record.id()), personalQuest.snapshot()); assertTrue(adventures.abandon(personalQuest.id(), personalQuest.unit()).get(5, TimeUnit.SECONDS)); - controller.open(member.player); drain(f); - var memberIndividualMenu = member.inventories.getLast(); + var memberIndividualMenu = open(f, controller, member); var individualItem = offer(memberIndividualMenu, "Solo Camp"); - click(controller, member, memberIndividualMenu, individualItem, false); drain(f); + int memberFeedback = member.messages.size(); + click(controller, member, memberIndividualMenu, individualItem, false); + accepted(f, member, memberFeedback); var memberIndividual = adventures.activeFor(member.core.record.id(), party.id()).orElseThrow(); assertEquals(individual.id(), memberIndividual.site()); assertEquals(AdventureQuestState.UnitType.SOLO, memberIndividual.unitType()); @@ -158,9 +167,11 @@ final class AdventureParticipationModeNativeTest { var partyless = new FantasyStickLifecycleNativeTest.Actor(data); partyless.core.location.setWorld(leader.world); f.players.put(partyless.core.id, partyless.player); - f.activate(partyless); controller.open(partyless.player); drain(f); - var partylessMenu = partyless.inventories.getLast(); - click(controller, partyless, partylessMenu, offer(partylessMenu, "Solo Camp"), false); drain(f); + f.activate(partyless); + var partylessMenu = open(f, controller, partyless); + int partylessFeedback = partyless.messages.size(); + click(controller, partyless, partylessMenu, offer(partylessMenu, "Solo Camp"), false); + accepted(f, partyless, partylessFeedback); var partylessQuest = adventures.activeFor(partyless.core.record.id(), null).orElseThrow(); assertEquals(AdventureQuestState.UnitType.SOLO, partylessQuest.unitType()); assertTrue(adventures.abandon(partylessQuest.id(), partylessQuest.unit()).get(5, TimeUnit.SECONDS)); @@ -170,18 +181,18 @@ final class AdventureParticipationModeNativeTest { click(controller, partyless, partylessMenu, partylessRequired, true); assertTrue(partyless.messages.getLast().contains("Only the active party leader")); - controller.open(leader.player); drain(f); - var leaderIndividualMenu = leader.inventories.getLast(); + var leaderIndividualMenu = open(f, controller, leader); var leaderIndividualItem = offer(leaderIndividualMenu, "Solo Camp"); - click(controller, leader, leaderIndividualMenu, leaderIndividualItem, false); drain(f); + int leaderFeedback = leader.messages.size(); + click(controller, leader, leaderIndividualMenu, leaderIndividualItem, false); + accepted(f, leader, leaderFeedback); var leaderIndividual = adventures.activeFor(leader.core.record.id(), party.id()).orElseThrow(); assertEquals(AdventureQuestState.UnitType.SOLO, leaderIndividual.unitType()); assertTrue(adventures.abandon(leaderIndividual.id(), leaderIndividual.unit()).get(5, TimeUnit.SECONDS)); click(controller, leader, leaderIndividualMenu, leaderIndividualItem, true); assertTrue(leader.messages.getLast().contains("individual-only")); - controller.open(leader.player); drain(f); - var leaderMenu = leader.inventories.getLast(); + var leaderMenu = open(f, controller, leader); var requiredItem = java.util.stream.IntStream.range(0, leaderMenu.getSize()) .mapToObj(leaderMenu::getItem).filter(java.util.Objects::nonNull) .filter(item -> PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName()) @@ -203,9 +214,10 @@ final class AdventureParticipationModeNativeTest { party = parties.create(leader.core.id).orElseThrow(); assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id())); + int partyFeedback = leader.messages.size(); controller.click(partyClick); UUID acceptedParty = party.id(); - await(f, () -> adventures.activeFor(leader.core.record.id(), acceptedParty).isPresent()); + accepted(f, leader, partyFeedback); var partyQuest = adventures.activeFor(leader.core.record.id(), acceptedParty).orElseThrow(); assertEquals(AdventureQuestState.UnitType.PARTY, partyQuest.unitType()); assertEquals(Set.of(leader.core.record.id(), member.core.record.id()), partyQuest.snapshot()); @@ -228,18 +240,19 @@ final class AdventureParticipationModeNativeTest { when(event.getCurrentItem()).thenReturn(item); when(event.isRightClick()).thenReturn(right); controller.click(event); } - private static void drain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception { - for (int attempt = 0; attempt < 20; attempt++) { - var task = f.tasks.poll(50, TimeUnit.MILLISECONDS); - if (task == null) break; - task.run(); - } + private static org.bukkit.inventory.Inventory open(FantasyStickLifecycleNativeTest.Fixture f, + AdventureQuestController controller, FantasyStickLifecycleNativeTest.Actor actor) throws Exception { + int before = actor.inventories.size(); + controller.open(actor.player); + await(f, () -> actor.inventories.size() > before); + return actor.inventories.getLast(); + } + private static void accepted(FantasyStickLifecycleNativeTest.Fixture f, + FantasyStickLifecycleNativeTest.Actor actor, int before) throws Exception { + await(f, () -> actor.messages.stream().skip(before).anyMatch(message -> message.startsWith("Accepted "))); } private static void await(FantasyStickLifecycleNativeTest.Fixture f, java.util.function.BooleanSupplier condition) throws Exception { - for (int attempt = 0; attempt < 50 && !condition.getAsBoolean(); attempt++) { - drain(f); Thread.sleep(5); - } - assertTrue(condition.getAsBoolean()); + NativeTestAwait.until(f, condition, "requested participation-mode result"); } } diff --git a/src/nativeTest/java/games/dmg/fantasystick/DungeonNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/DungeonNativeTest.java index ae18951..ad8263f 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/DungeonNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/DungeonNativeTest.java @@ -50,10 +50,10 @@ class DungeonNativeTest { assertTrue(f.command.tabComplete(admin.player,"fantasystick",new String[]{"dungeon",""}).contains("teleport")); assertEquals(List.of(disabled.id().toString(),enabled.id().toString()).stream().sorted().toList(), f.command.tabComplete(admin.player,"fantasystick",new String[]{"dungeon","teleport",""})); - f.command.execute(admin.player,"fantasystick",new String[]{"dungeon","teleport",disabled.id().toString()});drain(f);drain(f); + command(f,admin,"dungeon","teleport",disabled.id().toString()); var first=teleports.getFirst();assertSame(destination.world,first.getWorld());assertEquals(34.75,first.getX());assertEquals(66.5,first.getY());assertEquals(18.25,first.getZ());assertEquals(91.5f,first.getYaw());assertEquals(-7.25f,first.getPitch()); assertEquals("Teleported to Disabled Vault (dungeon entrance) at Dungeon Realm (34, 66, 18).",admin.messages.getLast()); - f.command.execute(admin.player,"fantasystick",new String[]{"dungeon","teleport",enabled.id().toString()});drain(f);drain(f); + command(f,admin,"dungeon","teleport",enabled.id().toString()); var second=teleports.getLast();assertEquals(-20.5,second.getX());assertEquals(55.25,second.getY());assertEquals(-33.75,second.getZ());assertEquals(-135f,second.getYaw());assertEquals(22f,second.getPitch()); assertTrue(admin.messages.getLast().contains("Enabled Depths (dungeon entrance) at Dungeon Realm (-21, 55, -34)")); assertEquals(before,directory.snapshot()); @@ -81,12 +81,12 @@ class DungeonNativeTest { actor.particleCalls.clear();pulse(f);assertFalse(actor.particleCalls.isEmpty());assertTrue(actor.particleCalls.size()<=1024);assertTrue(actor.particleCalls.stream().allMatch(call->call[0]==Particle.DUST));var particlePreferences=(games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"feedbackPreferences");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,false).toCompletableFuture().get(5,TimeUnit.SECONDS)));actor.particleCalls.clear();pulse(f);assertTrue(actor.particleCalls.isEmpty(),"All-particle opt-out suppresses dungeon-editor boundaries");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,true).toCompletableFuture().get(5,TimeUnit.SECONDS)));var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);doReturn(true).when(outsider.player).hasPermission("fantasystick.admin");command(f,outsider,"dungeon","edit","start",dungeon.id().toString());assertTrue(outsider.messages.getLast().contains("editing lease"));f.command.execute(outsider.player,"fantasystick",new String[]{"dungeon","reward",dungeon.id().toString(),"90","90"});assertTrue(outsider.messages.getLast().contains("another administrator"));assertEquals(BigInteger.valueOf(30),dungeons.find(dungeon.id()).orElseThrow().completionReward().xp());assertTrue(outsider.particleCalls.isEmpty()); command(f,actor,"dungeon","edit","done");actor.particleCalls.clear();pulse(f);assertTrue(actor.particleCalls.isEmpty());command(f,actor,"dungeon","enable",dungeon.id().toString());command(f,actor,"dungeon","open",dungeon.id().toString()); var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");assertTrue(adventures.snapshot().catalog().sites().contains(dungeon.id()));assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),adventures.reward(adventures.findSite(dungeon.id()).orElseThrow())); - f.activate(actor);var questController=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");questController.open(actor.player);drain(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var offerLore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(offerLore.contains("Completion trophy pool: 30 XP, 5 coins"));assertTrue(offerLore.contains("additional variable XP, coins and native loot"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));questController.click(click);drain(f);assertEquals(dungeon.id(),adventures.active().getFirst().site());var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);for(var member:List.of(partyA,partyB)){member.core.location.setWorld(actor.world);member.core.location.setX(100);f.players.put(member.core.id,member.player);doReturn(java.util.Set.of()).when(member.player).getActivePotionEffects();doReturn(true).when(member.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());f.activate(member);}var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,TimeUnit.SECONDS).status()); + f.activate(actor);var questController=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");var menu=open(f,questController,actor);assertEquals(Material.PAPER,menu.getItem(0).getType());var offerLore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(offerLore.contains("Completion trophy pool: 30 XP, 5 coins"));assertTrue(offerLore.contains("additional variable XP, coins and native loot"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));int acceptedFeedback=actor.messages.size();questController.click(click);await(f,()->actor.messages.stream().skip(acceptedFeedback).anyMatch(message->message.startsWith("Accepted ")),actor.messages);assertEquals(dungeon.id(),adventures.active().getFirst().site());var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);for(var member:List.of(partyA,partyB)){member.core.location.setWorld(actor.world);member.core.location.setX(100);f.players.put(member.core.id,member.player);doReturn(java.util.Set.of()).when(member.player).getActivePotionEffects();doReturn(true).when(member.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());f.activate(member);}var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,TimeUnit.SECONDS).status()); var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var before=storage.find(actor.core.record.id()).get().orElseThrow();var partyABefore=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBBefore=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);} - pulse(f);drain(f);pulse(f);drain(f);var initialMobs=spawned.stream().filter(Husk.class::isInstance).toList();assertEquals(2,initialMobs.size());var ordinary=initialMobs.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(ordinary).orElseThrow().kind());var copiedSlot=mock(Husk.class);when(copiedSlot.getUniqueId()).thenReturn(UUID.randomUUID());var copiedProof=ordinary.getPersistentDataContainer();when(copiedSlot.getPersistentDataContainer()).thenReturn(copiedProof);assertTrue(dungeonController.encounterOrigin(copiedSlot).isEmpty(),"a copied valid dungeon slot tag is not an authenticated registered spawn");var nativeDrop=new ItemStack(Material.ROTTEN_FLESH);var forgedDeath=new EntityDeathEvent(copiedSlot,mock(org.bukkit.damage.DamageSource.class),new ArrayList<>(List.of(nativeDrop)),7);f.dispatcher.accept(forgedDeath);assertEquals(7,forgedDeath.getDroppedExp());assertEquals(List.of(nativeDrop),forgedDeath.getDrops());assertTrue(dungeonController.encounterOrigin(ordinary).isPresent(),"copied callback did not consume the real dungeon slot");var survivor=initialMobs.getLast();assertNotEquals(dungeonController.encounterOrigin(ordinary),dungeonController.encounterOrigin(survivor),"each slot has independent kill authority");var survivorOrigin=dungeonController.encounterOrigin(survivor);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));var drops=new ArrayList();drops.add(new ItemStack(Material.ROTTEN_FLESH));var ordinaryDeath=new EntityDeathEvent(ordinary,source,drops,7);f.dispatcher.accept(ordinaryDeath);await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(run->run.defeatedSlots().size()==1).orElse(false),actor.messages);assertEquals(7,ordinaryDeath.getDroppedExp());assertFalse(ordinaryDeath.getDrops().isEmpty());var partial=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertEquals(1,partial.defeatedSlots().size());assertFalse(partial.clearedRooms().contains(first.id()));for(var member:List.of(actor,partyA))member.core.location.setX(100);pulse(f);verify(survivor,atLeastOnce()).remove();assertFalse(actorEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}pulse(f);drain(f);var respawned=spawned.stream().filter(Husk.class::isInstance).toList().getLast();assertNotSame(survivor,respawned);assertEquals(survivorOrigin,dungeonController.encounterOrigin(respawned),"respawned slot retains duplicate-safe kill authority");f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDeathEvent(respawned,source,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow().clearedRooms().contains(first.id()),actor.messages);assertTrue(storage.find(partyB.core.record.id()).get().orElseThrow().experience().compareTo(partyBBefore.experience())>0,"ordinary party combat sharing remains active"); - var actorAfterKills=storage.find(actor.core.record.id()).get().orElseThrow();var partyAAfterKills=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfterKills=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var firstBoss=spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();for(var member:List.of(actor,partyA,partyB))member.core.location.setX(100);pulse(f);verify(firstBoss,atLeastOnce()).remove();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var boss=spawned.stream().filter(Vindicator.class::isInstance).toList().getLast();assertNotSame(firstBoss,boss);f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDeathEvent(boss,source,new ArrayList<>(),0));assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(boss).orElseThrow().kind(),"boss cleanup retains immutable managed origin authority"); + pulse(f);await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).isPresent(),actor.messages);pulse(f);var initialMobs=spawned.stream().filter(Husk.class::isInstance).toList();assertEquals(2,initialMobs.size());var ordinary=initialMobs.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(ordinary).orElseThrow().kind());var copiedSlot=mock(Husk.class);when(copiedSlot.getUniqueId()).thenReturn(UUID.randomUUID());var copiedProof=ordinary.getPersistentDataContainer();when(copiedSlot.getPersistentDataContainer()).thenReturn(copiedProof);assertTrue(dungeonController.encounterOrigin(copiedSlot).isEmpty(),"a copied valid dungeon slot tag is not an authenticated registered spawn");var nativeDrop=new ItemStack(Material.ROTTEN_FLESH);var forgedDeath=new EntityDeathEvent(copiedSlot,mock(org.bukkit.damage.DamageSource.class),new ArrayList<>(List.of(nativeDrop)),7);f.dispatcher.accept(forgedDeath);assertEquals(7,forgedDeath.getDroppedExp());assertEquals(List.of(nativeDrop),forgedDeath.getDrops());assertTrue(dungeonController.encounterOrigin(ordinary).isPresent(),"copied callback did not consume the real dungeon slot");var survivor=initialMobs.getLast();assertNotEquals(dungeonController.encounterOrigin(ordinary),dungeonController.encounterOrigin(survivor),"each slot has independent kill authority");var survivorOrigin=dungeonController.encounterOrigin(survivor);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));var drops=new ArrayList();drops.add(new ItemStack(Material.ROTTEN_FLESH));var ordinaryDeath=new EntityDeathEvent(ordinary,source,drops,7);f.dispatcher.accept(ordinaryDeath);await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(run->run.defeatedSlots().size()==1).orElse(false),actor.messages);assertEquals(7,ordinaryDeath.getDroppedExp());assertFalse(ordinaryDeath.getDrops().isEmpty());var partial=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertEquals(1,partial.defeatedSlots().size());assertFalse(partial.clearedRooms().contains(first.id()));for(var member:List.of(actor,partyA))member.core.location.setX(100);pulse(f);verify(survivor,atLeastOnce()).remove();assertFalse(actorEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}pulse(f);await(f,()->spawned.stream().filter(Husk.class::isInstance).count()>initialMobs.size(),actor.messages);var respawned=spawned.stream().filter(Husk.class::isInstance).toList().getLast();assertNotSame(survivor,respawned);assertEquals(survivorOrigin,dungeonController.encounterOrigin(respawned),"respawned slot retains duplicate-safe kill authority");f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDeathEvent(respawned,source,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow().clearedRooms().contains(first.id()),actor.messages);assertTrue(storage.find(partyB.core.record.id()).get().orElseThrow().experience().compareTo(partyBBefore.experience())>0,"ordinary party combat sharing remains active"); + var actorAfterKills=storage.find(actor.core.record.id()).get().orElseThrow();var partyAAfterKills=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfterKills=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);await(f,()->spawned.stream().anyMatch(Vindicator.class::isInstance),actor.messages);var firstBoss=spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();for(var member:List.of(actor,partyA,partyB))member.core.location.setX(100);pulse(f);verify(firstBoss,atLeastOnce()).remove();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);await(f,()->spawned.stream().filter(Vindicator.class::isInstance).count()>1,actor.messages);var boss=spawned.stream().filter(Vindicator.class::isInstance).toList().getLast();assertNotSame(firstBoss,boss);f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDeathEvent(boss,source,new ArrayList<>(),0));assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(boss).orElseThrow().kind(),"boss cleanup retains immutable managed origin authority"); await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(DungeonState.Run::defeated).orElse(false)&&adventures.active().isEmpty()&&storage.find(actor.core.record.id()).join().orElseThrow().experience().compareTo(actorAfterKills.experience())>0,actor.messages);var run=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertTrue(run.defeated(),"dungeon run not defeated");assertEquals(boss.getUniqueId().toString(),run.completionAudit().get("boss_id"),"global completion must retain immutable native boss proof for recovery");assertFalse(run.clearedRooms().contains(skipped.id()),"boss death completes without clearing every room");assertTrue(adventures.active().isEmpty(),"boss did not close all dungeon quests");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),actor.core.record.id()),"solo completion claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()),"represented party member claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyB.core.record.id()),"present snapshotted party member claim missing");var after=storage.find(actor.core.record.id()).get().orElseThrow();assertTrue(after.experience().compareTo(before.experience())>0,"ordinary dungeon kills award standard combat XP");assertTrue(after.coins().compareTo(before.coins())>0,"ordinary dungeon kills award standard character coins");assertTrue(after.experience().compareTo(actorAfterKills.experience())>0,"genuinely killed dungeon boss grants native-classified per-kill XP");assertTrue(after.coins().compareTo(actorAfterKills.coins())>0,"genuinely killed dungeon boss grants native-classified per-kill coins");assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),actor.core.record.id()));var partyAAfter=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfter=storage.find(partyB.core.record.id()).get().orElseThrow();assertTrue(partyAAfter.experience().compareTo(partyABefore.experience())>0,"party contributor receives normal kill XP");assertTrue(partyBAfter.experience().compareTo(partyBBefore.experience())>0);assertTrue(partyAAfter.experience().compareTo(partyAAfterKills.experience())>0);assertTrue(partyBAfter.experience().compareTo(partyBAfterKills.experience())>0);var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var soloTrophy=trophies.trophies(actor.core.record.id()).getFirst();var partyTrophy=trophies.trophies(partyA.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.DUNGEON,soloTrophy.type());assertEquals("Trophy of Sunken Keep",soloTrophy.name());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),soloTrophy.total());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),partyTrophy.total());assertEquals(Set.of(partyA.core.record.id(),partyB.core.record.id()),partyTrophy.rewards().keySet());assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()));var defeatedGeneration=adventures.snapshot().catalog().revision();command(f,actor,"dungeon","close",dungeon.id().toString(),"confirm");command(f,actor,"dungeon","open",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("open refused"),actor.messages.toString());assertTrue(dungeons.runView(dungeon.id(),defeatedGeneration).orElseThrow().defeated(),"defeated generation changed");command(f,actor,"adventure","reroll","confirm");await(f,()->adventures.snapshot().reset()==null&&!adventures.snapshot().catalog().revision().equals(defeatedGeneration),actor.messages);assertEquals(1,trophies.trophies(actor.core.record.id()).size(),"reroll preserves earned dungeon trophy");var claimBase=storage.find(actor.core.record.id()).get().orElseThrow();var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");var npcChunk=mock(Chunk.class);when(npcChunk.getWorld()).thenReturn(actor.world);when(npcChunk.getX()).thenReturn(actor.core.location.getBlockX()>>4);when(npcChunk.getZ()).thenReturn(actor.core.location.getBlockZ()>>4);when(npcChunk.getEntities()).thenReturn(new Entity[0]);when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(npcChunk);when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{npcChunk});command(f,actor,"quest-giver","place","Dungeon Steward");var giverLocation=adventures.snapshot().givers().getFirst();actor.core.location.setX(giverLocation.x());actor.core.location.setZ(giverLocation.z());questController.reconcile(npcChunk);var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow();int menusBeforeGiver=actor.inventories.size();f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(actor.player,giver,org.bukkit.inventory.EquipmentSlot.HAND));await(f,()->actor.inventories.size()>menusBeforeGiver,actor.messages);var giverMenu=actor.inventories.getLast();var plain=net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();var entry=java.util.stream.IntStream.range(0,giverMenu.getSize()).mapToObj(giverMenu::getItem).filter(Objects::nonNull).filter(item->item.getItemMeta().displayName()!=null&&plain.serialize(item.getItemMeta().displayName()).startsWith("Turn in quest items")).findFirst().orElseThrow();var giverClick=mock(InventoryClickEvent.class);when(giverClick.getWhoClicked()).thenReturn(actor.player);when(giverClick.getInventory()).thenReturn(giverMenu);when(giverClick.getCurrentItem()).thenReturn(entry);questController.click(giverClick);var trophyMenu=actor.inventories.getLast();var redeem=java.util.stream.IntStream.range(0,trophyMenu.getSize()).mapToObj(trophyMenu::getItem).filter(Objects::nonNull).filter(item->item.getItemMeta().displayName()!=null&&plain.serialize(item.getItemMeta().displayName()).equals("Redeem Trophy of Sunken Keep")).findFirst().orElseThrow();var redeemClick=mock(InventoryClickEvent.class);when(redeemClick.getWhoClicked()).thenReturn(actor.player);when(redeemClick.getInventory()).thenReturn(trophyMenu);when(redeemClick.getCurrentItem()).thenReturn(redeem);turn.click(redeemClick);await(f,()->trophies.trophies(actor.core.record.id()).isEmpty()&&trophies.unclaimed(actor.core.record.id()).equals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5))),actor.messages);await(f,()->actor.inventories.getLast()!=trophyMenu,actor.messages);var claimMenu=actor.inventories.getLast();var claim=java.util.stream.IntStream.range(0,claimMenu.getSize()).mapToObj(claimMenu::getItem).filter(Objects::nonNull).filter(item->item.getItemMeta().displayName()!=null&&plain.serialize(item.getItemMeta().displayName()).equals("Claim earned quest rewards")).findFirst().orElseThrow();var claimClick=mock(InventoryClickEvent.class);when(claimClick.getWhoClicked()).thenReturn(actor.player);when(claimClick.getInventory()).thenReturn(claimMenu);when(claimClick.getCurrentItem()).thenReturn(claim);turn.click(claimClick);await(f,()->trophies.unclaimed(actor.core.record.id()).equals(games.dmg.fantasystick.adventure.FishingState.Reward.empty())&&storage.find(actor.core.record.id()).join().orElseThrow().experience().compareTo(claimBase.experience().add(BigInteger.valueOf(30)))>=0,actor.messages);var claimed=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(claimBase.coins().add(BigInteger.valueOf(5)),claimed.coins());assertTrue(dungeons.snapshot().runs().isEmpty(),"full reset retained dungeon run");command(f,actor,"dungeon","open",dungeon.id().toString());var resetCatalog=adventures.snapshot().catalog();assertTrue(resetCatalog.sites().contains(dungeon.id()),"reopened dungeon absent from replacement catalog");assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(resetCatalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS).status());command(f,actor,"dungeon","edit","start",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("exact confirm"),actor.messages.toString());assertFalse(adventures.active().isEmpty(),"fresh dungeon quest missing");command(f,actor,"dungeon","edit","start",dungeon.id().toString(),"confirm");await(f,()->dungeonController.editing(actor.core.id).isPresent(),actor.messages);assertTrue(adventures.active().isEmpty(),"confirmed edit retained accepted quest");assertFalse(dungeons.find(dungeon.id()).orElseThrow().enabled()); - await(f,()->exportedDungeonJourneyReady(data),actor.messages); + NativeTestAwait.auditUntil(f, () -> exportedDungeonJourneyReady(data), Boolean.TRUE::equals, "complete dungeon journey exports"); } var exported=exportedDungeonEvents(data); var changes=exported.stream().filter(event->event.get("type").getAsString() @@ -158,7 +158,7 @@ class DungeonNativeTest { .endsWith("adventure.reward_claimed")&&value.getAsJsonObject("data") .getAsJsonArray("sources").asList().stream().anyMatch(source->source.getAsJsonObject() .get("source_kind").getAsString().equals("dungeon_trophy"))); - }catch(Exception transientExport){return false;} + }catch(Exception failure){throw new AssertionError(failure);} } private static List exportedDungeonEvents(Path root)throws Exception{ try(var files=java.nio.file.Files.walk(root.resolve("events"))){ @@ -288,7 +288,7 @@ class DungeonNativeTest { f.players.remove(leader.core.id); assertNull(f.plugin.getServer().getPlayer(leader.core.id), "the trophy carrier is offline when the boss dies"); - pulse(f);drain(f);pulse(f);drain(f); + pulse(f);await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).isPresent(),actor.messages);pulse(f); var boss=spawned.getLast();defeatedBoss[0]=boss.getUniqueId(); var source=mock(org.bukkit.damage.DamageSource.class); when(source.getCausingEntity()).thenReturn(actor.player); @@ -309,7 +309,7 @@ class DungeonNativeTest { .map(DungeonState.Run::defeated).orElse(false),actor.messages); assertEquals(defeatedBoss[0].toString(),dungeons.runView(dungeon.id(),catalog.revision()) .orElseThrow().completionAudit().get("boss_id")); - assertEquals(0,exportedDungeonEvents(root).stream().filter(event->event.get("type") + assertEquals(0,NativeTestAwait.readAudit(f, () -> exportedDungeonEvents(root)).stream().filter(event->event.get("type") .getAsString().endsWith("dungeon.completed")).count()); } assertTrue(new games.dmg.fantasystick.persistence.YamlDungeonRepository(root).load().runs() @@ -317,9 +317,9 @@ class DungeonNativeTest { .equals(run.completionAudit().get("boss_id")))); try(var restarted=new FantasyStickLifecycleNativeTest.Fixture(root)){ restarted.plugin.setEnabled(true); - await(restarted,()->{try{return exportedDungeonEvents(root).stream().anyMatch(event-> - event.get("type").getAsString().endsWith("dungeon.completed"));} - catch(Exception transientExport){return false;}},List.of()); + NativeTestAwait.auditUntil(restarted, () -> exportedDungeonEvents(root), + records -> records.stream().anyMatch(event -> event.get("type").getAsString() + .endsWith("dungeon.completed")), "replayed dungeon completion"); var runs=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(restarted.plugin,"dungeons"); await(restarted,()->runs.snapshot().runs().stream().anyMatch(run->run.defeated() &&defeatedBoss[0].toString().equals(run.completionAudit().get("boss_id")) @@ -394,8 +394,8 @@ class DungeonNativeTest { doAnswer(call->{var mob=mock(Vindicator.class);var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());var maximum=mock(org.bukkit.attribute.AttributeInstance.class);var damage=mock(org.bukkit.attribute.AttributeInstance.class);var equipment=mock(org.bukkit.inventory.EntityEquipment.class);var maximumBase=new double[]{20};var attackBase=new double[]{1};var health=new double[]{20};var effects=new LinkedHashSet();effects.add(new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.WEAKNESS,200,0));maximumBases.put(mob,maximumBase);attackBases.put(mob,attackBase);bossEffects.put(mob,effects);bossEquipment.put(mob,equipment);when(mob.getPersistentDataContainer()).thenReturn(pdc);when(mob.getUniqueId()).thenReturn(UUID.randomUUID());when(mob.getType()).thenReturn(EntityType.VINDICATOR);when(mob.getWorld()).thenReturn(admin.world);when(mob.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());when(mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(maximum);when(mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)).thenReturn(damage);when(maximum.getValue()).thenAnswer(ignored->maximumBase[0]);doAnswer(invocation->{maximumBase[0]=(Double)invocation.getArgument(0);return null;}).when(maximum).setBaseValue(anyDouble());doAnswer(invocation->{attackBase[0]=(Double)invocation.getArgument(0);return null;}).when(damage).setBaseValue(anyDouble());when(mob.getHealth()).thenAnswer(ignored->health[0]);doAnswer(invocation->{health[0]=(Double)invocation.getArgument(0);return null;}).when(mob).setHealth(anyDouble());when(mob.getEquipment()).thenReturn(equipment);when(mob.hasLineOfSight(any(Entity.class))).thenReturn(true);when(mob.getActivePotionEffects()).thenAnswer(ignored->Set.copyOf(effects));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(invocation->{effects.removeIf(effect->effect.getType().equals(invocation.getArgument(0)));return null;}).when(mob).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));((java.util.function.Consumer)call.getArgument(2)).accept(mob);spawned.add(mob);return mob;}).when(admin.world).spawn(any(Location.class),eq(Vindicator.class),any(java.util.function.Consumer.class)); when(admin.world.getEntities()).thenAnswer(ignored->new ArrayList(spawned)); var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Mirror",worldId,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);dungeons.setCompletionReward(dungeon.id(),BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS);command(f,admin,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(admin.core.id).isPresent(),admin.messages);admin.core.location.setX(.5);admin.core.location.setY(65);admin.core.location.setZ(.5);var sheet=admin.core.inventory.getItemInOffHand();admin.core.inventory.setItemInOffHand(null);admin.core.inventory.setItemInMainHand(sheet);command(f,admin,"dungeon","room","boss","assign-sheet","2");await(f,()->dungeons.custody(admin.core.record.id()).isPresent(),admin.messages);command(f,admin,"dungeon","edit","done");command(f,admin,"dungeon","enable",dungeon.id().toString());command(f,admin,"dungeon","open",dungeon.id().toString());var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");await(f,()->adventures.snapshot().catalog()!=null&&adventures.snapshot().catalog().sites().contains(dungeon.id()),admin.messages); - var fighter=new FantasyStickLifecycleNativeTest.Actor(root);fighter.core.location.setWorld(admin.world);fighter.core.location.setX(.5);fighter.core.location.setY(65);fighter.core.location.setZ(.5);f.players.put(fighter.core.id,fighter.player);doReturn(false).when(fighter.player).isDead();doReturn(true).when(fighter.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());var abilityHits=new ArrayList();fighter.damage=(amount,source)->abilityHits.add(amount);f.activate(fighter);var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,fighter.core.record.id(),Set.of(fighter.core.record.id())).get(5,TimeUnit.SECONDS).status());var challenger=new FantasyStickLifecycleNativeTest.Actor(root);challenger.core.location.setWorld(admin.world);challenger.core.location.setX(1.5);challenger.core.location.setY(65);challenger.core.location.setZ(.5);f.players.put(challenger.core.id,challenger.player);doReturn(false).when(challenger.player).isDead();doReturn(true).when(challenger.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());challenger.damage=(amount,sourceEntity)->{};f.activate(challenger);assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,challenger.core.record.id(),Set.of(challenger.core.record.id())).get(5,TimeUnit.SECONDS).status());pulse(f);drain(f);pulse(f);drain(f);assertFalse(spawned.isEmpty());var boss=spawned.getLast();assertEquals(70d,maximumBases.get(boss)[0]);assertEquals(26d,attackBases.get(boss)[0]);var equipment=bossEquipment.get(boss);verify(equipment).setChestplate(argThat(item->item!=null&&item.getType()==Material.COPPER_CHESTPLATE));verify(equipment).setLeggings(argThat(item->item!=null&&item.getType()==Material.GOLDEN_LEGGINGS));verify(equipment).setBoots(argThat(item->item!=null&&item.getType()==Material.IRON_BOOTS));assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.SPEED)),bossEffects.get(boss)::toString);assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.NIGHT_VISION)),bossEffects.get(boss)::toString);boss.setHealth(50);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(fighter.player);when(source.getDirectEntity()).thenReturn(fighter.player);f.dispatcher.accept(new EntityDamageByEntityEvent(fighter.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));pulse(f);verify(boss,atLeastOnce()).setTarget(fighter.player);assertTrue(abilityHits.size()>=2,"Every ready offensive slot casts in the same pulse");int firstCasts=abilityHits.size();pulse(f);assertEquals(firstCasts,abilityHits.size(),"Boss-only cooldowns suppress immediate recasts");assertFalse(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.WEAKNESS)),"Purify removes a real harmful effect");var challengerSource=mock(org.bukkit.damage.DamageSource.class);when(challengerSource.getCausingEntity()).thenReturn(challenger.player);when(challengerSource.getDirectEntity()).thenReturn(challenger.player);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,10));pulse(f);var targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(fighter.player,targets.getAllValues().getLast(),"Higher later threat cannot break fixation");fighter.core.location.setX(100);pulse(f);targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(challenger.player,targets.getAllValues().getLast(),"Ineligible fixation retargets highest remaining threat"); - challenger.core.location.setX(100);pulse(f);verify(boss,atLeastOnce()).remove();challenger.core.location.setX(.5);pulse(f);drain(f);var freshBoss=spawned.getLast();killedBossId[0]=freshBoss.getUniqueId();assertNotSame(boss,freshBoss);assertEquals(70d,maximumBases.get(freshBoss)[0]);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,freshBoss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,2));f.dispatcher.accept(new EntityDeathEvent(freshBoss,challengerSource,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).map(DungeonState.Run::defeated).orElse(false),admin.messages);assertTrue(dungeons.custody(admin.core.record.id()).isPresent(),"Boss defeat keeps character custody for later days");await(f,()->{try{var published=exportedDungeonEvents(root);return published.stream().anyMatch(event->event.get("type").getAsString().endsWith("dungeon.completed"))&&published.stream().anyMatch(event->event.get("type").getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data").get("trophy_type").getAsString().equals("dungeon_trophy"));}catch(Exception transientExport){return false;}},admin.messages);challenger.core.location.setX(100); + var fighter=new FantasyStickLifecycleNativeTest.Actor(root);fighter.core.location.setWorld(admin.world);fighter.core.location.setX(.5);fighter.core.location.setY(65);fighter.core.location.setZ(.5);f.players.put(fighter.core.id,fighter.player);doReturn(false).when(fighter.player).isDead();doReturn(true).when(fighter.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());var abilityHits=new ArrayList();fighter.damage=(amount,source)->abilityHits.add(amount);f.activate(fighter);var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,fighter.core.record.id(),Set.of(fighter.core.record.id())).get(5,TimeUnit.SECONDS).status());var challenger=new FantasyStickLifecycleNativeTest.Actor(root);challenger.core.location.setWorld(admin.world);challenger.core.location.setX(1.5);challenger.core.location.setY(65);challenger.core.location.setZ(.5);f.players.put(challenger.core.id,challenger.player);doReturn(false).when(challenger.player).isDead();doReturn(true).when(challenger.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());challenger.damage=(amount,sourceEntity)->{};f.activate(challenger);assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,challenger.core.record.id(),Set.of(challenger.core.record.id())).get(5,TimeUnit.SECONDS).status());pulse(f);await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).isPresent(),admin.messages);pulse(f);assertFalse(spawned.isEmpty());var boss=spawned.getLast();assertEquals(70d,maximumBases.get(boss)[0]);assertEquals(26d,attackBases.get(boss)[0]);var equipment=bossEquipment.get(boss);verify(equipment).setChestplate(argThat(item->item!=null&&item.getType()==Material.COPPER_CHESTPLATE));verify(equipment).setLeggings(argThat(item->item!=null&&item.getType()==Material.GOLDEN_LEGGINGS));verify(equipment).setBoots(argThat(item->item!=null&&item.getType()==Material.IRON_BOOTS));assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.SPEED)),bossEffects.get(boss)::toString);assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.NIGHT_VISION)),bossEffects.get(boss)::toString);boss.setHealth(50);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(fighter.player);when(source.getDirectEntity()).thenReturn(fighter.player);f.dispatcher.accept(new EntityDamageByEntityEvent(fighter.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));pulse(f);verify(boss,atLeastOnce()).setTarget(fighter.player);assertTrue(abilityHits.size()>=2,"Every ready offensive slot casts in the same pulse");int firstCasts=abilityHits.size();pulse(f);assertEquals(firstCasts,abilityHits.size(),"Boss-only cooldowns suppress immediate recasts");assertFalse(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.WEAKNESS)),"Purify removes a real harmful effect");var challengerSource=mock(org.bukkit.damage.DamageSource.class);when(challengerSource.getCausingEntity()).thenReturn(challenger.player);when(challengerSource.getDirectEntity()).thenReturn(challenger.player);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,10));pulse(f);var targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(fighter.player,targets.getAllValues().getLast(),"Higher later threat cannot break fixation");fighter.core.location.setX(100);pulse(f);targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(challenger.player,targets.getAllValues().getLast(),"Ineligible fixation retargets highest remaining threat"); + challenger.core.location.setX(100);pulse(f);verify(boss,atLeastOnce()).remove();challenger.core.location.setX(.5);pulse(f);await(f,()->spawned.getLast()!=boss,admin.messages);var freshBoss=spawned.getLast();killedBossId[0]=freshBoss.getUniqueId();assertNotSame(boss,freshBoss);assertEquals(70d,maximumBases.get(freshBoss)[0]);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,freshBoss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,2));f.dispatcher.accept(new EntityDeathEvent(freshBoss,challengerSource,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).map(DungeonState.Run::defeated).orElse(false),admin.messages);assertTrue(dungeons.custody(admin.core.record.id()).isPresent(),"Boss defeat keeps character custody for later days");await(f,()->{try{var published=exportedDungeonEvents(root);return published.stream().anyMatch(event->event.get("type").getAsString().endsWith("dungeon.completed"))&&published.stream().anyMatch(event->event.get("type").getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data").get("trophy_type").getAsString().equals("dungeon_trophy"));}catch(Exception transientExport){return false;}},admin.messages);challenger.core.location.setX(100); int disableFeedback = admin.messages.size(); command(f,admin,"dungeon","disable",dungeon.id().toString(),"confirm"); ReskillEventsNativeTest.await(f,()->admin.messages.stream().skip(disableFeedback) @@ -404,7 +404,7 @@ class DungeonNativeTest { assertFalse(adventures.offerOpen(dungeon.id())); assertTrue(adventures.findSite(dungeon.id()).isEmpty()); admin.core.location.setX(100);command(f,admin,"dungeon","boss","recover-sheet",dungeon.id().toString());await(f,()->dungeons.custody(admin.core.record.id()).isEmpty(),admin.messages);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(configuredBoss,storage.find(configuredBoss.id()).get(5,TimeUnit.SECONDS).orElseThrow());assertTrue(Arrays.stream(admin.core.inventory.getContents()).filter(Objects::nonNull).anyMatch(item->new games.dmg.fantasystick.items.CharacterSheets().identify(item).filter(configuredBoss.id()::equals).isPresent())); - await(f,()->{try{var published=exportedDungeonEvents(root);return published.stream().anyMatch(event->event.get("type").getAsString().endsWith("dungeon.completed"))&&published.stream().anyMatch(event->event.get("type").getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data").get("trophy_type").getAsString().equals("dungeon_trophy"));}catch(Exception transientExport){return false;}},admin.messages); + NativeTestAwait.auditUntil(f, () -> exportedDungeonEvents(root), published -> published.stream().anyMatch(event->event.get("type").getAsString().endsWith("dungeon.completed"))&&published.stream().anyMatch(event->event.get("type").getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data").get("trophy_type").getAsString().equals("dungeon_trophy")), "completion and trophy exports"); } var exported=exportedDungeonEvents(root); var bossAwards=exported.stream().filter(event->event.get("type").getAsString() @@ -427,11 +427,21 @@ class DungeonNativeTest { } @Test void questGiverPaginatesManualDungeonOffersBeyondAutomaticCount() throws Exception { - java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);f.activate(actor);var directory=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var sites=new ArrayList();for(int i=0;i<46;i++)sites.add(new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.DUNGEON,"Dungeon "+i,world,i,64,0));var catalog=new AdventureQuestState.Catalog(java.time.LocalDate.now(java.time.ZoneOffset.UTC),UUID.randomUUID(),java.time.Instant.now(),sites.stream().map(AdventureQuestState.Site::id).toList());var field=directory.getClass().getDeclaredField("snapshot");field.setAccessible(true);field.set(directory,new AdventureQuestState(sites,List.of(),catalog,List.of(),Set.of(),Set.of(),Map.of()));var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.open(actor.player);drain(f);var first=actor.inventories.getLast();assertEquals(54,first.getSize());for(int slot=0;slot<45;slot++)assertEquals(Material.PAPER,first.getItem(slot).getType());assertEquals(Material.ARROW,first.getItem(53).getType());var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(first);when(click.getCurrentItem()).thenReturn(first.getItem(53));controller.click(click);drain(f);var second=actor.inventories.getLast();assertNotSame(first,second);assertEquals(Material.PAPER,second.getItem(0).getType());assertTrue(second.getItem(0).getItemMeta().displayName().toString().contains("Dungeon 45"));} + java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);f.activate(actor);var directory=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var sites=new ArrayList();for(int i=0;i<46;i++)sites.add(new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.DUNGEON,"Dungeon "+i,world,i,64,0));var catalog=new AdventureQuestState.Catalog(java.time.LocalDate.now(java.time.ZoneOffset.UTC),UUID.randomUUID(),java.time.Instant.now(),sites.stream().map(AdventureQuestState.Site::id).toList());var field=directory.getClass().getDeclaredField("snapshot");field.setAccessible(true);field.set(directory,new AdventureQuestState(sites,List.of(),catalog,List.of(),Set.of(),Set.of(),Map.of()));var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");var first=open(f,controller,actor);assertEquals(54,first.getSize());for(int slot=0;slot<45;slot++)assertEquals(Material.PAPER,first.getItem(slot).getType());assertEquals(Material.ARROW,first.getItem(53).getType());var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(first);when(click.getCurrentItem()).thenReturn(first.getItem(53));int beforePage=actor.inventories.size();controller.click(click);await(f,()->actor.inventories.size()>beforePage,actor.messages);var second=actor.inventories.getLast();assertNotSame(first,second);assertEquals(Material.PAPER,second.getItem(0).getType());assertTrue(second.getItem(0).getItemMeta().displayName().toString().contains("Dungeon 45"));} } - private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String... args)throws Exception{f.command.execute(actor.player,"fantasystick",args);drain(f);} + private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String... args)throws Exception{ + // These serialized authoring routes respond only at their terminal native outcome. + int before=actor.messages.size(); + f.command.execute(actor.player,"fantasystick",args); + NativeTestAwait.until(f,()->actor.messages.size()>before,"native response to "+String.join(" ",args)); + } + private static org.bukkit.inventory.Inventory open(FantasyStickLifecycleNativeTest.Fixture f, + AdventureQuestController controller,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{ + int before=actor.inventories.size();controller.open(actor.player); + NativeTestAwait.until(f,()->actor.inventories.size()>before,"new Quest Giver menu"); + return actor.inventories.getLast(); + } private static void pulse(FantasyStickLifecycleNativeTest.Fixture f){for(var task:List.copyOf(f.pulses))task.run();} - private static void drain(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{Runnable task;for(int attempt=0;attempt<20;attempt++){task=f.tasks.poll(50,TimeUnit.MILLISECONDS);if(task==null)break;task.run();}} - private static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition,Object evidence)throws Exception{for(int attempt=0;attempt<50&&!condition.getAsBoolean();attempt++){drain(f);Thread.sleep(5);}assertTrue(condition.getAsBoolean(),String.valueOf(evidence));} + private static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition,Object evidence)throws Exception{NativeTestAwait.until(f,condition,"dungeon result: "+evidence);} } diff --git a/src/nativeTest/java/games/dmg/fantasystick/DungeonRoomEffectLifetimeNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/DungeonRoomEffectLifetimeNativeTest.java index 5928082..56bb4e4 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/DungeonRoomEffectLifetimeNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/DungeonRoomEffectLifetimeNativeTest.java @@ -98,7 +98,7 @@ class DungeonRoomEffectLifetimeNativeTest { fixture.actor.core.location.setX(70); fixture.shared.pulse(); if (port.cancelRemoval) { assertEquals(port.aged(beforeExit, 1), port.current(PotionEffectType.SPEED)); - assertEquals(0, port.removals); assertTrue(rows("effect.ended").isEmpty()); + assertEquals(0, port.removals); assertTrue(NativeTestAwait.readAudit(fixture.f, () -> rows("effect.ended")).isEmpty()); port.cancelRemoval = false; fixture.shared.pulse(); } if (port.cancelExtend) { @@ -120,15 +120,15 @@ class DungeonRoomEffectLifetimeNativeTest { assertEquals(fixture.quest, fixture.shared.directory.findQuest(fixture.quest.id()).orElseThrow()); // Observe gameplay exports while the logger is open; shutdown may retain valid durable intents. try { - // These owner intents were submitted by the already verified native calls. - // A ready main-thread callback must not consume this IO-only 100 x 50 ms budget. - for (int attempt = 0; attempt < 100 - && (rows("effect.applied").size() != 1 || rows("effect.ended").size() != 1); attempt++) - Thread.sleep(50); - assertEquals(1, rows("effect.applied").size()); - assertEquals(1, rows("effect.ended").size()); + var exported = NativeTestAwait.auditUntil(fixture.f, + () -> List.of(rows("effect.applied"), rows("effect.ended")), + rows -> rows.get(0).size() == 1 && rows.get(1).size() == 1, + "one applied and one ended room effect"); + assertEquals(1, exported.get(0).size()); + assertEquals(1, exported.get(1).size()); } catch (AssertionError failure) { - var applied = rows("effect.applied"); var ended = rows("effect.ended"); + var applied = NativeTestAwait.readAudit(fixture.f, () -> rows("effect.applied")); + var ended = NativeTestAwait.readAudit(fixture.f, () -> rows("effect.ended")); throw new AssertionError("Room export fence: applied=" + applied.size() + ", appliedLogical=" + applied.stream().map(row -> row.get("id").getAsString()).distinct().count() + ", ended=" + ended.size() diff --git a/src/nativeTest/java/games/dmg/fantasystick/FeatureAuditSaturationNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/FeatureAuditSaturationNativeTest.java index c30b76c..2bf82ea 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/FeatureAuditSaturationNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/FeatureAuditSaturationNativeTest.java @@ -22,6 +22,17 @@ class FeatureAuditSaturationNativeTest { @TempDir Path data; @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + private static java.util.List rows(FantasyStickLifecycleNativeTest.Fixture f, + Path root, String type) throws Exception { + return NativeTestAwait.readAudit(f, () -> QuestTrackingEventsNativeTest.rows(root, type)); + } + + private static void awaitRows(FantasyStickLifecycleNativeTest.Fixture f, Path root, + String type, int count) throws Exception { + NativeTestAwait.auditUntil(f, () -> QuestTrackingEventsNativeTest.rows(root, type), + records -> records.size() >= count, type + " export"); + } + @Test void actualPluginStartupAutomaticallyExportsOriginalIntentAfterOtherOwnerCapacityReturns() throws Exception { var pluginData = data.resolve("startup-plugin"); var saved = new CopyOnWriteArrayList(); @@ -47,27 +58,26 @@ class FeatureAuditSaturationNativeTest { assertTrue(full.await(30, TimeUnit.SECONDS)); try (var restarted = new FantasyStickLifecycleNativeTest.Fixture(pluginData)) { restarted.plugin.setEnabled(true); // Actual production startup invokes replay, not this test. - ReskillEventsNativeTest.await(restarted, () -> { - try { return ReskillEventsNativeTest.records(pluginData).stream() - .filter(row -> row.get("type").getAsString().endsWith("game.started")).count() == 2; } - catch (Exception failure) { throw new AssertionError(failure); } - }); - assertTrue(rows(pluginData, "action.observed").stream().noneMatch(row -> row.get("id").getAsString().equals(proof.id().toString()))); + NativeTestAwait.auditUntil(restarted, () -> ReskillEventsNativeTest.records(pluginData), + records -> records.stream().filter(row -> row.get("type").getAsString() + .endsWith("game.started")).count() == 2, "second startup export"); + assertTrue(rows(restarted, pluginData, "action.observed").stream().noneMatch(row -> row.get("id").getAsString().equals(proof.id().toString()))); assertTrue(Files.exists(pluginData.resolve("quest-tracking-event-intents").resolve(proof.id() + ".pending"))); held.complete(LocalEventLog.Delivery.REJECTED); - ReskillEventsNativeTest.await(restarted, () -> { - try { return rows(pluginData, "action.observed").stream() - .anyMatch(row -> row.get("id").getAsString().equals(proof.id().toString())); } - catch (Exception failure) { throw new AssertionError(failure); } - }); - var exported = rows(pluginData, "action.observed").stream() + var replayed = NativeTestAwait.auditUntil(restarted, + () -> QuestTrackingEventsNativeTest.rows(pluginData, "action.observed"), + records -> records.stream().anyMatch(row -> row.get("id").getAsString().equals(proof.id().toString())), + "original replay export"); + var exported = replayed.stream() .filter(row -> row.get("id").getAsString().equals(proof.id().toString())).toList(); assertEquals(1, exported.size()); var row = exported.getFirst(); assertEquals(proof.time().toString(), row.get("time").getAsString()); assertEquals(proof.context().playerId().toString(), row.get("playerid").getAsString()); for (var entry : proof.data().entrySet()) assertEquals(new com.google.gson.Gson().toJsonTree(entry.getValue()), row.getAsJsonObject("data").get(entry.getKey()), entry.getKey()); - assertFalse(Files.exists(pluginData.resolve("quest-tracking-event-intents").resolve(proof.id() + ".pending"))); + var pending = pluginData.resolve("quest-tracking-event-intents").resolve(proof.id() + ".pending"); + NativeTestAwait.until(restarted, () -> !Files.exists(pending), "export acknowledgment retired original intent"); + assertFalse(Files.exists(pending)); } } finally { held.complete(LocalEventLog.Delivery.REJECTED); blocker.close(); blocker.drained().toCompletableFuture().get(10, TimeUnit.SECONDS); } } @@ -118,7 +128,7 @@ class FeatureAuditSaturationNativeTest { ReskillEventsNativeTest.await(f, () -> actor.messages.contains("Quest tracking is now on.")); assertTrue(fixture.directory.trackingEnabled(quest.id(), actor.core.record.id())); assertEquals(Material.COMPASS, actor.core.inventory.getItem(0).getType()); - assertTrue(rows(data.resolve("logger-plugin"), "preference.read").isEmpty()); + assertTrue(rows(f, data.resolve("logger-plugin"), "preference.read").isEmpty()); } finally { releaseDiagnostic.countDown(); f.plugin.getLogger().removeHandler(handler); held.complete(LocalEventLog.Delivery.REJECTED); blocker.close(); @@ -165,9 +175,9 @@ class FeatureAuditSaturationNativeTest { assertArrayEquals(source, Files.readAllBytes(data.resolve("plugin/adventure-quests.yml"))); var after = actor.core.snapshot(); for (int i = 1; i < inventory.length; i++) assertEquals(inventory[i], after[i]); - assertEquals(1, rows(data.resolve("plugin"), "preference.changed").size()); - assertTrue(rows(data.resolve("plugin"), "preference.read").isEmpty()); - assertTrue(rows(data.resolve("plugin"), "action.observed").isEmpty()); + assertEquals(1, rows(f, data.resolve("plugin"), "preference.changed").size()); + assertTrue(rows(f, data.resolve("plugin"), "preference.read").isEmpty()); + assertTrue(rows(f, data.resolve("plugin"), "action.observed").isEmpty()); } finally { held.complete(LocalEventLog.Delivery.REJECTED); blocker.close(); @@ -177,7 +187,7 @@ class FeatureAuditSaturationNativeTest { // Once capacity recovers, the actual registered query reaches parsed JSONL again. f.playerCommand.execute(actor.player, "fstick", new String[]{"adventure", "tracking"}); awaitRows(f, data.resolve("plugin"), "preference.read", 1); - assertTrue(rows(data.resolve("plugin"), "preference.read").getFirst().getAsJsonObject("data").get("saved_value").getAsBoolean()); + assertTrue(rows(f, data.resolve("plugin"), "preference.read").getFirst().getAsJsonObject("data").get("saved_value").getAsBoolean()); } } } diff --git a/src/nativeTest/java/games/dmg/fantasystick/NativeTestAwait.java b/src/nativeTest/java/games/dmg/fantasystick/NativeTestAwait.java new file mode 100644 index 0000000..3d261bf --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/NativeTestAwait.java @@ -0,0 +1,59 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; +import java.util.function.Predicate; + +/** Test-only acknowledgments; gameplay time and production executors remain unchanged. */ +final class NativeTestAwait { + private NativeTestAwait() { } + + static void until(FantasyStickLifecycleNativeTest.Fixture fixture, BooleanSupplier completed, + String description) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!completed.getAsBoolean()) pump(fixture, deadline, description); + } + + /** Returns the same strict snapshot that satisfied the predicate, never a later unfenced reread. */ + static T auditUntil(FantasyStickLifecycleNativeTest.Fixture fixture, Callable read, + Predicate completed, String description) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (true) { + var snapshot = readAudit(fixture, read, remaining(deadline, description)); + if (completed.test(snapshot)) return snapshot; + pump(fixture, deadline, description); + } + } + + private static void pump(FantasyStickLifecycleNativeTest.Fixture fixture, long deadline, + String description) throws Exception { + var task = fixture.tasks.poll(Math.min(remaining(deadline, description), + TimeUnit.MILLISECONDS.toNanos(10)), TimeUnit.NANOSECONDS); + if (task != null) task.run(); + } + + private static long remaining(long deadline, String description) { + long remaining = deadline - System.nanoTime(); + assertTrue(remaining > 0, "Missing acknowledgment within five seconds: " + description); + return remaining; + } + + static T readAudit(FantasyStickLifecycleNativeTest.Fixture fixture, Callable read) throws Exception { + return readAudit(fixture, read, TimeUnit.SECONDS.toNanos(5)); + } + + static T readAudit(FantasyStickLifecycleNativeTest.Fixture fixture, Callable read, + long remainingNanos) throws Exception { + // Run the entire strict read on this fixture's serial writer, not merely a fence before + // an unsynchronized read. Later appends cannot interleave with parsing the snapshot. + var writer = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field( + FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog"), "worker"); + try { return writer.submit(read).get(remainingNanos, TimeUnit.NANOSECONDS); } + catch (ExecutionException failure) { throw new AssertionError(failure.getCause()); } + } +} diff --git a/src/nativeTest/java/games/dmg/fantasystick/NativeTestAwaitNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/NativeTestAwaitNativeTest.java new file mode 100644 index 0000000..ea9423d --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/NativeTestAwaitNativeTest.java @@ -0,0 +1,51 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class NativeTestAwaitNativeTest { + @TempDir Path data; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @Test void quietQueueAndUnrelatedCallbackDoNotAcknowledgeTheRequestedResult() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + var result = new AtomicBoolean(); + var unrelated = new AtomicBoolean(); + var returned = new CountDownLatch(1); + var premature = new AtomicBoolean(); + f.tasks.add(() -> unrelated.set(true)); + var delayed = CompletableFuture.runAsync(() -> { + try { premature.set(returned.await(200, TimeUnit.MILLISECONDS)); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new AssertionError(failure); } + finally { f.tasks.add(() -> result.set(true)); } + }); + try { + NativeTestAwait.until(f, result::get, "requested native result"); + returned.countDown(); delayed.get(5, TimeUnit.SECONDS); + assertTrue(unrelated.get()); + assertTrue(result.get()); + assertFalse(premature.get(), "an empty queue is not completion of delayed work"); + } finally { + returned.countDown(); delayed.get(5, TimeUnit.SECONDS); + } + } + } + + @Test void acknowledgedResultReturnsWithoutConsumingUnrelatedQueuedWork() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + var unrelated = new AtomicBoolean(); + f.tasks.add(() -> unrelated.set(true)); + NativeTestAwait.until(f, () -> true, "already acknowledged"); + assertFalse(unrelated.get(), "acknowledgment is not a request to drain arbitrary work"); + assertEquals(1, f.tasks.size()); + } + } +} diff --git a/src/nativeTest/java/games/dmg/fantasystick/RareShopNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/RareShopNativeTest.java index c73f818..f9755cf 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/RareShopNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/RareShopNativeTest.java @@ -33,7 +33,7 @@ class RareShopNativeTest { f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);int x=actor.core.location.getBlockX()>>4,z=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(x);when(chunk.getZ()).thenReturn(z);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(x,z)).thenReturn(chunk);when(actor.world.isChunkLoaded(x,z)).thenReturn(true);when(actor.world.getEntities()).thenReturn(List.of()); assertEquals(List.of("list","place","remove","restock"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"rare-shop",""}));assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-"}).containsAll(List.of("rare-price-health-potion","rare-price-reskill","rare-stock-reskill-min","rare-stock-reskill-max","rare-price-magic-core","rare-stock-magic-core-min","rare-stock-magic-core-max","rare-price-phoenix-down","rare-stock-phoenix-down-min","rare-stock-phoenix-down-max","rare-phoenix-down-invulnerability-seconds")));assertEquals(List.of("20","2000"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-price-reskill",""}));assertEquals(List.of("30","3000"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-price-magic-core",""})); var villager=mock(Villager.class);when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap();var pdc=mock(PersistentDataContainer.class);when(villager.getPersistentDataContainer()).thenReturn(pdc);doAnswer(call->{values.put(((NamespacedKey)call.getArgument(0)).getKey(),call.getArgument(2));return null;}).when(pdc).set(any(NamespacedKey.class),eq(PersistentDataType.STRING),anyString());when(pdc.get(any(NamespacedKey.class),eq(PersistentDataType.STRING))).thenAnswer(call->values.get(((NamespacedKey)call.getArgument(0)).getKey()));doAnswer(call->{((java.util.function.Consumer)call.getArgument(2)).accept(villager);return villager;}).when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class)); - f.command.execute(actor.player,"fantasystick",new String[]{"rare-shop","place","Curios"});await(f,()->((RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants")).all().size()==1);var directory=(RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants");var shop=directory.all().getFirst();assertEquals("Curios",shop.name());var controller=(RareMerchantController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchantController");assertTrue(controller.tagged(villager)); + f.command.execute(actor.player,"fantasystick",new String[]{"rare-shop","place","Curios"});await(f,()->((RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants")).all().size()==1);var directory=(RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants");var shop=directory.all().getFirst();assertEquals("Curios",shop.name());var controller=(RareMerchantController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchantController");NativeTestAwait.until(f,()->controller.tagged(villager),"saved merchant reached native spawn/tagging");assertTrue(controller.tagged(villager)); var repository=new YamlCharacterRepository(data);var old=repository.load(actor.core.record.id()).orElseThrow();var purchasedStats=new java.util.EnumMap<>(old.stats());purchasedStats.put(games.dmg.fantasystick.progression.Stat.DEX,BigInteger.ONE);repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),purchasedStats,old.level(),old.experience(),BigInteger.valueOf(39),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(150),true,old.rewardReceipts(),old.pvpCoinEscrow(),old.gear()),old.revision());f.activate(actor); int opened=actor.inventories.size(),books=actor.books.size();var main=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);var offhand=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.OFF_HAND);f.dispatcher.accept(main);f.dispatcher.accept(offhand);assertTrue(main.isCancelled());assertTrue(offhand.isCancelled());await(f,()->actor.inventories.size()==opened+1);assertEquals(books,actor.books.size());var menu=actor.inventories.getLast();var potion=menu.getItem(11);assertEquals(Material.POTION,potion.getType());assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(potion.getItemMeta().displayName()).contains("Health Potion — 40 coins"));assertEquals("Stock: 1",net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(potion.getItemMeta().lore().getFirst())); var menus=(RareShopMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareShopMenus");var click=mock(InventoryClickEvent.class);when(click.getInventory()).thenReturn(menu);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getCurrentItem()).thenReturn(potion);menus.click(click);var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION).equals(BigInteger.ONE));var characters=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");await(f,()->sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.coins().equals(BigInteger.valueOf(110))).orElse(false));var active=sessions.active(actor.core.id).orElseThrow(()->new AssertionError("Purchase removed active session: "+actor.messages));var record=sessions.currentRecord(active).orElseThrow(()->new AssertionError("Purchase left no current record: "+actor.messages));var injured=characters.saveHealth(record.id(),record.revision(),BigDecimal.valueOf(5),BigDecimal.valueOf(100)).join().orElseThrow(()->new AssertionError("Injury save was refused"));assertTrue(sessions.acknowledgeProgress(active,record.revision(),injured));actor.player.setHealth(5);