diff --git a/src/nativeTest/java/games/dmg/fantasystick/FantasyStickLifecycleNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/FantasyStickLifecycleNativeTest.java index 0da977b..7745492 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/FantasyStickLifecycleNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/FantasyStickLifecycleNativeTest.java @@ -1090,6 +1090,7 @@ class FantasyStickLifecycleNativeTest { if (!value.equals("default")) java.nio.file.Files.writeString(directory.resolve("config.yml"), "character-swap-cooldown-seconds: " + value + "\n"); try (var f = new Fixture(directory)) { f.plugin.setEnabled(true); var a = new Actor(directory); f.players.put(a.core.id, a.player); f.activate(a); + org.mockito.Mockito.doReturn(null).when(a.player).getFishHook(); var characters = (CharacterStorage) Fixture.field(f.plugin, "characters"); var worker = (java.util.concurrent.ExecutorService) Fixture.field(characters, "worker"); var entered = new java.util.concurrent.CountDownLatch(1); @@ -1126,6 +1127,7 @@ class FantasyStickLifecycleNativeTest { f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null)); f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED")); } + JourneyAcknowledgmentNativeFixture.awaitSwitchExport(f, directory, exit.sessionId()); } finally { release.countDown(); } } var records = new java.util.ArrayList(); diff --git a/src/nativeTest/java/games/dmg/fantasystick/FishingQuestNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/FishingQuestNativeTest.java index e15c44c..fb5c8e3 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/FishingQuestNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/FishingQuestNativeTest.java @@ -24,6 +24,8 @@ import org.junit.jupiter.api.io.TempDir; final class FishingQuestNativeTest { @TempDir Path data; @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + java.util.function.Consumer beforeAcceptance = ignored -> {}; + Runnable acceptanceReadComplete = () -> {}; @SuppressWarnings("unchecked") @Test void registeredFishingDefinitionCatchInventorySubmissionResetAndClaimUseConnectedPath() throws Exception { @@ -40,6 +42,7 @@ final class FishingQuestNativeTest { var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var site=adventures.snapshot().sites().getFirst();assertEquals(AdventureQuestState.SiteType.FISHING,site.type());assertEquals("Moonlit Pond",site.name());assertEquals("Silver Scale",site.fishing().itemName());assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,site.participationMode()); f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString()});assertTrue(actor.messages.getLast().contains("Silver Scale"));assertEquals(List.of(site.id().toString()),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","fishing",""})); var giver=mock(Villager.class); + when(giver.getType()).thenReturn(EntityType.VILLAGER); var giverData=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer( new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()); when(giver.getPersistentDataContainer()).thenReturn(giverData); @@ -61,7 +64,7 @@ final class FishingQuestNativeTest { int before=adventures.snapshot().sites().size();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","Broken","--item","X","--required","0","--chance","101","--xp","1","--coins","1"});assertEquals(before,adventures.snapshot().sites().size()); var oldCatalog=adventures.snapshot().catalog();f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});await(f,()->adventures.snapshot().reset()==null&&adventures.snapshot().catalog()!=null&&(oldCatalog==null||!adventures.snapshot().catalog().revision().equals(oldCatalog.revision())));var catalog=adventures.snapshot().catalog();assertTrue(catalog.sites().contains(site.id())); var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController"); - f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drain(f);var offerMenu=actor.inventories.getLast();var offer=offer(offerMenu,"Moonlit Pond");var lore=plain(offer);assertTrue(lore.stream().anyMatch(line->line.contains("Mode: individual-only")));assertTrue(lore.stream().anyMatch(line->line.contains("Silver Scale × 1")));assertTrue(lore.stream().anyMatch(line->line.contains("Catch chance: 100%")));assertTrue(lore.stream().anyMatch(line->line.contains("10 XP, 4 coins")));click(controller,actor,offerMenu,offer);drain(f);var quest=adventures.active().getFirst();assertNotNull(quest.fishing());UUID stableItem=quest.fishing().itemId();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString(),"--item","\"Golden Scale\"","--required","2","--chance","0","--xp","20","--coins","8","--radius","4"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated fishing site")&&message.contains(site.id().toString())));var updatedSite=adventures.findSite(site.id()).orElseThrow();assertEquals(stableItem,updatedSite.fishing().itemId());assertEquals("Golden Scale",updatedSite.fishing().itemName());assertEquals("Silver Scale",quest.fishing().itemName(),"accepted terms remain immutable");assertEquals(16,quest.fishing().radius());f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","mode",site.id().toString(),"party-required"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated adventure site "+site.id()+" mode=party-required")));actor.core.location.setX(10); + f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drain(f);var offerMenu=actor.inventories.getLast();var offer=offer(offerMenu,"Moonlit Pond");var lore=plain(offer);assertTrue(lore.stream().anyMatch(line->line.contains("Mode: individual-only")));assertTrue(lore.stream().anyMatch(line->line.contains("Silver Scale × 1")));assertTrue(lore.stream().anyMatch(line->line.contains("Catch chance: 100%")));assertTrue(lore.stream().anyMatch(line->line.contains("10 XP, 4 coins")));beforeAcceptance.accept(f);click(controller,actor,offerMenu,offer);AdventureQuestState.Quest quest;try{quest=JourneyAcknowledgmentNativeFixture.awaitFishingAcceptance(f,adventures,actor,catalog.revision(),site.id());}finally{acceptanceReadComplete.run();}assertNotNull(quest.fishing());UUID stableItem=quest.fishing().itemId();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString(),"--item","\"Golden Scale\"","--required","2","--chance","0","--xp","20","--coins","8","--radius","4"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated fishing site")&&message.contains(site.id().toString())));var updatedSite=adventures.findSite(site.id()).orElseThrow();assertEquals(stableItem,updatedSite.fishing().itemId());assertEquals("Golden Scale",updatedSite.fishing().itemName());assertEquals("Silver Scale",quest.fishing().itemName(),"accepted terms remain immutable");assertEquals(16,quest.fishing().radius());f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","mode",site.id().toString(),"party-required"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated adventure site "+site.id()+" mode=party-required")));actor.core.location.setX(10); for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}var rod=actor.core.inventory.getItem(0);assertEquals(Material.FISHING_ROD,rod.getType());assertEquals(quest.id(),AdventureQuestNavigation.fishingQuest(rod));assertTrue(rod.getEnchantments().isEmpty()); var use=new PlayerInteractEvent(actor.player,Action.RIGHT_CLICK_AIR,rod,null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND);f.dispatcher.accept(use);assertNotEquals(org.bukkit.event.Event.Result.DENY,use.useItemInHand(),"owned quest rod keeps native cast/reel mechanics"); var ordinaryHook=mock(FishHook.class);when(ordinaryHook.getUniqueId()).thenReturn(UUID.randomUUID());var ordinaryEvent=new PlayerFishEvent(actor.player,null,ordinaryHook,EquipmentSlot.HAND,PlayerFishEvent.State.REEL_IN);actor.core.inventory.setItem(0,new ItemStack(Material.FISHING_ROD));f.dispatcher.accept(ordinaryEvent);assertFalse(ordinaryEvent.isCancelled(),"ordinary fishing remains unchanged"); @@ -744,6 +747,6 @@ final class FishingQuestNativeTest { private static void click(AdventureQuestController controller,FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.inventory.Inventory menu,ItemStack item){var event=mock(InventoryClickEvent.class);when(event.getWhoClicked()).thenReturn(actor.player);when(event.getInventory()).thenReturn(menu);when(event.getCurrentItem()).thenReturn(item);controller.click(event);} private static void clickFishing(FishingController controller,FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.inventory.Inventory menu,ItemStack item){var event=mock(InventoryClickEvent.class);when(event.getWhoClicked()).thenReturn(actor.player);when(event.getInventory()).thenReturn(menu);when(event.getCurrentItem()).thenReturn(item);controller.click(event);} private static void pulse(FantasyStickLifecycleNativeTest.Fixture f){for(var pulse:List.copyOf(f.pulses))pulse.run();} - private static void drain(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{for(int attempt=0;attempt<30;attempt++){var 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)throws Exception{for(int attempt=0;attempt<80&&!condition.getAsBoolean();attempt++){drain(f);Thread.sleep(5);}assertTrue(condition.getAsBoolean());} + static void drain(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{for(int attempt=0;attempt<30;attempt++){var task=f.tasks.poll(50,TimeUnit.MILLISECONDS);if(task==null)break;task.run();}} + static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition)throws Exception{for(int attempt=0;attempt<80&&!condition.getAsBoolean();attempt++){drain(f);Thread.sleep(5);}assertTrue(condition.getAsBoolean());} } diff --git a/src/nativeTest/java/games/dmg/fantasystick/JourneyAcknowledgmentNativeFixture.java b/src/nativeTest/java/games/dmg/fantasystick/JourneyAcknowledgmentNativeFixture.java new file mode 100644 index 0000000..433b75c --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/JourneyAcknowledgmentNativeFixture.java @@ -0,0 +1,34 @@ +package games.dmg.fantasystick; + +import games.dmg.fantasystick.adventure.AdventureQuestDirectory; +import games.dmg.fantasystick.adventure.AdventureQuestState; +import java.nio.file.Path; +import java.util.UUID; + +/** Scoped observation ports for the two approved switch/fishing journeys; never gameplay authority. */ +final class JourneyAcknowledgmentNativeFixture { + private JourneyAcknowledgmentNativeFixture() {} + + static void awaitSwitchExport(FantasyStickLifecycleNativeTest.Fixture f, Path directory, UUID session) throws Exception { + ReskillEventsNativeTest.await(f, () -> { + try { + return ReskillEventsNativeTest.records(directory).stream().anyMatch(row -> row.get("type").getAsString() + .endsWith("character.switch_delay_started") && row.getAsJsonObject("data") + .get("exit_operation_id").getAsString().equals(session.toString())); + } catch (Exception failure) { throw new AssertionError("Cannot read actual switch export", failure); } + }); + } + + static AdventureQuestState.Quest awaitFishingAcceptance(FantasyStickLifecycleNativeTest.Fixture f, + AdventureQuestDirectory directory, FantasyStickLifecycleNativeTest.Actor actor, + UUID catalog, UUID site) throws Exception { + var character = actor.core.record.id(); + var acceptedPrefix = "Accepted " + directory.findSite(site).orElseThrow().displayName() + ";"; + FishingQuestNativeTest.await(f, () -> directory.active().stream().anyMatch(quest -> quest.catalog().equals(catalog) + && quest.site().equals(site) && quest.unit().equals(character) && quest.snapshot().contains(character)) + && actor.messages.stream().anyMatch(message -> message.startsWith(acceptedPrefix))); + return directory.active().stream().filter(quest -> quest.catalog().equals(catalog) + && quest.site().equals(site) && quest.unit().equals(character) && quest.snapshot().contains(character)) + .findFirst().orElseThrow(); + } +} diff --git a/src/nativeTest/java/games/dmg/fantasystick/JourneyAcknowledgmentNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/JourneyAcknowledgmentNativeTest.java new file mode 100644 index 0000000..5586f25 --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/JourneyAcknowledgmentNativeTest.java @@ -0,0 +1,116 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import games.dmg.fantasystick.adventure.AdventureQuestDirectory; +import games.dmg.fantasystick.events.LocalEventLog; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +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; + +/** Real owned workers are held; observer threads only touch latches, never native state. */ +class JourneyAcknowledgmentNativeTest { + @TempDir Path data; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @Test void realPersistedLogoutMustWaitForItsOriginalSessionExportBeforeJourneyTeardown() throws Exception { + 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); f.activate(actor); + doReturn(null).when(actor.player).getFishHook(); + ((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")) + .ready().toCompletableFuture().get(5, TimeUnit.SECONDS); + var bridge = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "switchAuditEvents"); + var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(bridge, "worker"); + try (var held = new HeldWorker(worker)) { + f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(actor.player, + (net.kyori.adventure.text.Component) null)); + f.awaitCharacterAndReceipt(actor); + var exit = f.receipts().find(actor.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS) + .orElseThrow().lastExit(); + assertNotNull(exit, "Actual native logout durably acknowledged its original session exit"); + assertFalse(ReskillEventsNativeTest.records(data).stream().anyMatch(row -> row.get("type").getAsString() + .endsWith("character.switch_delay_started")), "The real export owner is still held"); + held.observe(); + try { JourneyAcknowledgmentNativeFixture.awaitSwitchExport(f, data, exit.sessionId()); } + finally { held.returned.countDown(); } + held.assertDidWait(); + var rows = ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString() + .endsWith("character.switch_delay_started")).toList(); + assertEquals(1, rows.size()); + var values = rows.getFirst().getAsJsonObject("data"); + assertEquals(exit.sessionId().toString(), values.get("exit_operation_id").getAsString()); + assertEquals(exit.characterId().toString(), values.get("character_id").getAsString()); + assertEquals(exit.exitedAt().toString(), values.get("exited_at").getAsString()); + assertEquals(exit.deadline().toString(), values.get("deadline").getAsString()); + } + } + } + + @Test void registeredFishingMenuJourneyMustWaitForItsActualAcceptanceNotFixedQueueDrains() throws Exception { + var journey = new FishingQuestNativeTest(); journey.data = data; + var held = new java.util.concurrent.atomic.AtomicReference(); + journey.beforeAcceptance = f -> { + var directory = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureQuests"); + var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(directory, "worker"); + try { + var control = new HeldWorker(worker); held.set(control); + assertTrue(directory.active().isEmpty()); + control.observe(); + } catch (Exception failure) { throw new AssertionError(failure); } + }; + journey.acceptanceReadComplete = () -> held.get().returned.countDown(); + try { + assertDoesNotThrow(journey::registeredFishingDefinitionCatchInventorySubmissionResetAndClaimUseConnectedPath, + "The real fishing menu acceptance must be acknowledged before the journey reads its exact active quest"); + assertNotNull(held.get()); held.get().assertDidWait(); + } finally { if (held.get() != null) held.get().close(); } + } + + private static final class HeldWorker implements AutoCloseable { + final CountDownLatch returned = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private final AtomicBoolean premature = new AtomicBoolean(); + private final Future block; + private CompletableFuture observer; + + HeldWorker(ExecutorService worker) throws Exception { + var entered = new CountDownLatch(1); + block = worker.submit(() -> { + entered.countDown(); + try { assertTrue(release.await(5, TimeUnit.SECONDS), "Controlled owner hold must be explicitly released"); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + } + void observe() { + observer = CompletableFuture.runAsync(() -> { + try { premature.set(returned.await(200, TimeUnit.MILLISECONDS)); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); } + finally { release.countDown(); } + }); + } + void assertDidWait() throws Exception { + observer.get(5, TimeUnit.SECONDS); + assertFalse(premature.get(), "Journey read/teardown returned before its actual owned acknowledgment"); + } + @Override public void close() throws java.io.IOException { + release.countDown(); + try { + if (observer != null) observer.get(5, TimeUnit.SECONDS); + block.get(5, TimeUnit.SECONDS); + } catch (Exception failure) { + if (failure instanceof InterruptedException) Thread.currentThread().interrupt(); + throw new java.io.IOException("Controlled owner hold did not finish cleanly", failure); + } + } + } +}