test(fantasy-stick): await switch and fishing acknowledgments
CI / build (push) Failing after 11m6s
CI / build (pull_request) Failing after 11m5s

This commit is contained in:
dmg committed 2026-10-06 22:28:05 -04:00
1 parent 441037f5ff
commit a630490f5b
4 files changed
+158 -3

No files matched your search

@@ -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<com.google.gson.JsonObject>();
@@ -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<FantasyStickLifecycleNativeTest.Fixture> 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());}
}
@@ -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();
}
}
@@ -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<HeldWorker>();
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<Void> 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);
}
}
}
}