test(fantasy-stick): await switch and fishing acknowledgments #2
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user