diff --git a/src/main/java/games/dmg/fantasystick/adventure/AdventureQuestController.java b/src/main/java/games/dmg/fantasystick/adventure/AdventureQuestController.java index 67811c5..e7ea327 100644 --- a/src/main/java/games/dmg/fantasystick/adventure/AdventureQuestController.java +++ b/src/main/java/games/dmg/fantasystick/adventure/AdventureQuestController.java @@ -138,7 +138,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable { private record Context(EquipmentSessions.Active active,UUID character,UUID partyId,boolean leader,Set members){} private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null)return new Context(active,record.id(),null,true,Set.of(record.id()));return new Context(active,record.id(),party.id(),party.leaderCharacter().equals(record.id()),Set.copyOf(party.characters()));} @EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(PlayerInteractEntityEvent event){if(!giver(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked(),idKey);if(id!=null)directory.findGiver(id).ifPresent(giver->open(event.getPlayer(),0,giver));} - @EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page,holder.giver);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){var now=directory.snapshot();if(now.reset()!=null||now.catalog()==null||!holder.catalog.equals(now.catalog().revision())){player.sendMessage("Quest item entry refused: the Quest Giver menu expired.");return;}fishing.open(player,holder.giver);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site,holder.giver);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}var audit=campEvents;var championAudit=championEvents;var actor=audit==null?null:audit.actor(player,null);var championActor=championQuestActor(actor,holder.giver);UUID operation=UUID.randomUUID();var capturedSite=directory.findSite(active.site()).orElse(null);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(active.site());directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->{if(failure==null&&changed){if(audit!=null)audit.questTransition("adventure.quest.abandoned",operation,actor,active,capturedSite,null,"player_abandoned");if(championAudit!=null)championAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,null,"player_abandoned");if(dungeonAudit!=null)try{dungeonAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,originalDungeon,"player_abandoned");}catch(RuntimeException ignored){}}schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);});});return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}var offered=directory.findSite(site).orElseThrow();var type=event.isRightClick()?AdventureQuestState.UnitType.PARTY:AdventureQuestState.UnitType.SOLO;if(!offered.participationMode().permits(type)){player.sendMessage("Quest acceptance refused: this site is "+offered.participationMode().value()+".");return;}if(type==AdventureQuestState.UnitType.PARTY&&(context.partyId==null||!context.leader)){player.sendMessage("Only the active party leader may accept a party quest.");return;}var unit=type==AdventureQuestState.UnitType.SOLO?context.character:context.partyId;var members=type==AdventureQuestState.UnitType.SOLO?Set.of(context.character):context.members;var campAudit=campEvents;var championAudit=championEvents;var campActor=campAudit==null?null:campAudit.actor(player,null);var championActor=championQuestActor(campActor,holder.giver);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(site);UUID operation=UUID.randomUUID();var trackingAudit=questTrackingEvents;var trackingAcceptance=captureTrackingAcceptance(operation,player,members);directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->{if(failure==null&&result.status()==AdventureQuestDirectory.Acceptance.APPLIED&&trackingAudit!=null&&trackingAcceptance!=null)try{trackingAudit.accepted(trackingAcceptance,result.quest().orElseThrow());}catch(RuntimeException ignored){}schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&¤t.active.equals(context.active)&¤t.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&¤t.members.equals(context.members));if(!same){directory.abandon(quest.id(),quest.unit()).whenComplete((ignored,rollbackFailure)->schedule(()->player.sendMessage(rollbackFailure==null?"Quest acceptance refused: the character or party changed.":"Quest acceptance is pending safe cancellation; retry after recovery.")));return;}var location=directory.findSite(site).orElseThrow();if(campAudit!=null)campAudit.questTransition("adventure.quest.accepted",operation,campActor,quest,result.capturedSite().orElse(null),type==AdventureQuestState.UnitType.PARTY?context.character:null,"accepted");if(championAudit!=null)championAudit.questTransition("adventure.quest.accepted",operation,championActor,quest,result.capturedSite().orElse(null),type==AdventureQuestState.UnitType.PARTY?context.character:null,"accepted");if(dungeonAudit!=null)try{dungeonAudit.questTransition("adventure.quest.accepted",operation,championActor,quest,result.capturedSite().orElse(null),originalDungeon,"accepted");}catch(RuntimeException ignored){}if(quest.fishing()!=null&&fishingEvents!=null)fishingEvents.accepted(quest.id(),fishingEvents.actor(player),quest);refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+quest.expiresAt()+". Follow the Quest Compass.");});});} + @EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page,holder.giver);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){var now=directory.snapshot();if(now.reset()!=null||now.catalog()==null||!holder.catalog.equals(now.catalog().revision())){player.sendMessage("Quest item entry refused: the Quest Giver menu expired.");return;}fishing.open(player,holder.giver);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site,holder.giver);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}var audit=campEvents;var championAudit=championEvents;var actor=audit==null?null:audit.actor(player,null);var championActor=championQuestActor(actor,holder.giver);UUID operation=UUID.randomUUID();var capturedSite=directory.findSite(active.site()).orElse(null);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(active.site());directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->{if(failure==null&&changed){if(audit!=null)audit.questTransition("adventure.quest.abandoned",operation,actor,active,capturedSite,null,"player_abandoned");if(championAudit!=null)championAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,null,"player_abandoned");if(dungeonAudit!=null)try{dungeonAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,originalDungeon,"player_abandoned");}catch(RuntimeException ignored){}}schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);});});return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}var offered=directory.findSite(site).orElseThrow();var type=event.isRightClick()?AdventureQuestState.UnitType.PARTY:AdventureQuestState.UnitType.SOLO;if(!offered.participationMode().permits(type)){player.sendMessage("Quest acceptance refused: this site is "+offered.participationMode().value()+".");return;}if(type==AdventureQuestState.UnitType.PARTY&&(context.partyId==null||!context.leader)){player.sendMessage("Only the active party leader may accept a party quest.");return;}var unit=type==AdventureQuestState.UnitType.SOLO?context.character:context.partyId;var members=type==AdventureQuestState.UnitType.SOLO?Set.of(context.character):context.members;var campAudit=campEvents;var championAudit=championEvents;var campActor=campAudit==null?null:campAudit.actor(player,null);var championActor=championQuestActor(campActor,holder.giver);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(site);UUID operation=UUID.randomUUID();var trackingAudit=questTrackingEvents;var trackingAcceptance=captureTrackingAcceptance(operation,player,members);directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->{if(failure==null&&result.status()==AdventureQuestDirectory.Acceptance.APPLIED&&trackingAudit!=null&&trackingAcceptance!=null)try{trackingAudit.accepted(trackingAcceptance,result.quest().orElseThrow());}catch(RuntimeException ignored){}schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&¤t.active.equals(context.active)&¤t.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&¤t.members.equals(context.members));if(!same){directory.abandon(quest.id(),quest.unit()).whenComplete((ignored,rollbackFailure)->schedule(()->player.sendMessage(rollbackFailure==null?"Quest acceptance refused: the character or party changed.":"Quest acceptance is pending safe cancellation; retry after recovery.")));return;}var location=directory.findSite(site).orElseThrow();if(campAudit!=null)campAudit.questTransition("adventure.quest.accepted",operation,championActor,quest,result.capturedSite().orElse(null),type==AdventureQuestState.UnitType.PARTY?context.character:null,"accepted");if(championAudit!=null)championAudit.questTransition("adventure.quest.accepted",operation,championActor,quest,result.capturedSite().orElse(null),type==AdventureQuestState.UnitType.PARTY?context.character:null,"accepted");if(dungeonAudit!=null)try{dungeonAudit.questTransition("adventure.quest.accepted",operation,championActor,quest,result.capturedSite().orElse(null),originalDungeon,"accepted");}catch(RuntimeException ignored){}if(quest.fishing()!=null&&fishingEvents!=null)fishingEvents.accepted(quest.id(),fishingEvents.actor(player),quest);refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+quest.expiresAt()+". Follow the Quest Compass.");});});} private void legacyResult(games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request,String outcome,String reason){ if(fishingEvents!=null)fishingEvents.turnInResolved(request,outcome,reason); } diff --git a/src/main/java/games/dmg/fantasystick/events/CampEventBridge.java b/src/main/java/games/dmg/fantasystick/events/CampEventBridge.java index 294e0ea..8f95542 100644 --- a/src/main/java/games/dmg/fantasystick/events/CampEventBridge.java +++ b/src/main/java/games/dmg/fantasystick/events/CampEventBridge.java @@ -67,6 +67,7 @@ public final class CampEventBridge implements AutoCloseable { try{ var data=siteData(site,arena); data.put("mode_before","not_applicable");data.put("mode_after",site.participationMode().value()); + data.put("application","future_acceptances"); data.put("catalog_impact","unchanged_by_registration");data.put("active_quest_impact","unchanged"); accepted("adventure.site.registered",operation,actor,data); }catch(RuntimeException ignored){/* Cannot undo acknowledged registration. */} @@ -103,7 +104,7 @@ public final class CampEventBridge implements AutoCloseable { var data=siteData(acknowledged,arena); data.put("mode_before",requestedBefore.participationMode().value());data.put("mode_after",acknowledged.participationMode().value()); if(requestedBefore.registrationRevision()!=null)data.put("requested_site_revision",requestedBefore.registrationRevision().toString()); - data.put("application_scope","future_acceptances");data.put("active_quest_impact","captured_authority_unchanged"); + data.put("application","future_acceptances");data.put("application_scope","future_acceptances");data.put("active_quest_impact","captured_authority_unchanged"); data.put("catalog_impact","same_offer_future_acceptances_use_new_mode"); accepted("adventure.site.mode_changed",operation,actor,data); }catch(RuntimeException ignored){/* Never alter mode publication. */} diff --git a/src/main/java/games/dmg/fantasystick/events/ChampionEventBridge.java b/src/main/java/games/dmg/fantasystick/events/ChampionEventBridge.java index 823c5eb..cda1839 100644 --- a/src/main/java/games/dmg/fantasystick/events/ChampionEventBridge.java +++ b/src/main/java/games/dmg/fantasystick/events/ChampionEventBridge.java @@ -27,6 +27,7 @@ public final class ChampionEventBridge implements AutoCloseable { if(site.type()!=AdventureQuestState.SiteType.CHAMPION)return; var data=CampEventBridge.siteData(site,arena); data.put("mode_before","not_applicable");data.put("mode_after",site.participationMode().value()); + data.put("application","future_acceptances"); data.put("catalog_impact","unchanged_by_registration");data.put("active_quest_impact","unchanged"); accepted("adventure.site.registered",operation,actor,data); }catch(RuntimeException ignored){/* Acknowledged authority is independent of audit availability. */} @@ -38,7 +39,7 @@ public final class ChampionEventBridge implements AutoCloseable { var data=CampEventBridge.siteData(acknowledged,arena); data.put("mode_before",requestedBefore.participationMode().value());data.put("mode_after",acknowledged.participationMode().value()); if(requestedBefore.registrationRevision()!=null)data.put("requested_site_revision",requestedBefore.registrationRevision().toString()); - data.put("application_scope","future_acceptances");data.put("active_quest_impact","captured_authority_unchanged"); + data.put("application","future_acceptances");data.put("application_scope","future_acceptances");data.put("active_quest_impact","captured_authority_unchanged"); data.put("catalog_impact","same_offer_future_acceptances_use_new_mode"); accepted("adventure.site.mode_changed",operation,actor,data); }catch(RuntimeException ignored){/* Mode publication never depends on audit. */} diff --git a/src/main/java/games/dmg/fantasystick/events/DungeonAuditEventBridge.java b/src/main/java/games/dmg/fantasystick/events/DungeonAuditEventBridge.java index d7cf88e..a28737e 100644 --- a/src/main/java/games/dmg/fantasystick/events/DungeonAuditEventBridge.java +++ b/src/main/java/games/dmg/fantasystick/events/DungeonAuditEventBridge.java @@ -28,13 +28,13 @@ public final class DungeonAuditEventBridge implements AutoCloseable { public void configChanged(CampEventBridge.Actor actor,UUID operation,String key,String before,String after,String defaultValue){if(!key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.DUNGEON_ROOM_LINK_DISTANCE)||before.equals(after))return;accepted("config.changed",operation,actor,Map.of("feature","dungeon","key",key,"before",before,"after",after,"default",defaultValue,"application_scope","future_definitions_and_offers","active_work_status","unchanged"));} public void created(UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon value){ var data=definition(value);data.put("after_revision",value.revision().toString());data.put("after_mode",value.participationMode().value()); - data.put("after_enabled",value.enabled());data.put("before_definition_status","absent");data.put("reason","administrator_create"); + data.put("application","future_acceptances");data.put("after_enabled",value.enabled());data.put("before_definition_status","absent");data.put("reason","administrator_create"); accepted("dungeon.created",operation,actor,data); } public void modeChanged(UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon before,DungeonState.Dungeon after){ if(before.participationMode()==after.participationMode())return; var data=definition(after);data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString()); - data.put("before_mode",before.participationMode().value());data.put("after_mode",after.participationMode().value());data.put("application_scope","future_acceptances"); + data.put("before_mode",before.participationMode().value());data.put("after_mode",after.participationMode().value());data.put("application","future_acceptances");data.put("application_scope","future_acceptances"); accepted("dungeon.mode_changed",operation,actor,data); } /** Project only original acknowledged definition changes; reward and character-boss owners stay independent. */ @@ -78,6 +78,7 @@ public final class DungeonAuditEventBridge implements AutoCloseable { private void questObserved(String type,UUID operation,CampEventBridge.Actor actor,games.dmg.fantasystick.adventure.AdventureQuestState.Quest quest,games.dmg.fantasystick.adventure.AdventureQuestState.Site site,DungeonState.Dungeon definition,String reason){ var data=definition==null?new LinkedHashMap():definition(definition);data.put("dungeon_id",quest.site().toString());data.put("site_capture_quality",site==null?"unavailable_legacy_source_identity_only":"original_acknowledged_site");if(definition==null){if(site!=null)data.put("dungeon_name",site.displayName());data.put("definition_revision_status","unavailable_not_in_captured_site");}data.put("definition_capture_quality",definition==null?"unavailable":"original_request_snapshot"); data.put("quest_id",quest.id().toString());data.put("catalog_id",quest.catalog().toString());data.put("generation_id",quest.catalog().toString());data.put("unit_id",quest.unit().toString());data.put("unit_kind",quest.unitType().name().toLowerCase(java.util.Locale.ROOT));data.put("accepted_mode",quest.participationMode().value());data.put("accepted_roster",quest.snapshot().stream().sorted().map(UUID::toString).toList());data.put("expires_at",quest.expiresAt().toString());data.put("reason",reason); + data.put("reward_xp_pool",quest.rewardXp().toString());data.put("reward_coin_pool",quest.rewardCoins().toString()); var qualified=UUID.nameUUIDFromBytes((operation+":"+quest.id()).getBytes(StandardCharsets.UTF_8));accepted(type,qualified,actor,data); } public void questExpired(games.dmg.fantasystick.adventure.AdventureQuestDirectory.QuestExpiry expired){questObserved("adventure.quest.expired",expired.operation(),expired.actor(),expired.quest(),expired.site(),null,expired.reason());} diff --git a/src/nativeTest/java/games/dmg/fantasystick/ParticipationBoundaryEventsNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/ParticipationBoundaryEventsNativeTest.java new file mode 100644 index 0000000..7c58b70 --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/ParticipationBoundaryEventsNativeTest.java @@ -0,0 +1,101 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; + +import games.dmg.fantasystick.dungeon.DungeonDirectory; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** Existing refusal safeguards exercised through installed native entry points and the real writer. */ +class ParticipationBoundaryEventsNativeTest { + @TempDir Path data; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @ParameterizedTest @ValueSource(strings={"camp", "champion", "dungeon"}) + void failedAcceptanceAndChangedPartyRollbackCannotPublishAcceptanceSuccess(String kind) throws Exception { + try (var dungeon = new DungeonAuditNativeFixture(data)) { + var shared = dungeon.shared; var f = shared.f; var leader = shared.actor; + if (kind.equals("dungeon")) { + dungeon.command("dungeon", "create", "Boundary Vault"); + String id = dungeon.directory.snapshot().dungeons().getFirst().id().toString(); + dungeon.command("dungeon", "edit", "start", id); + dungeon.command("dungeon", "room", "create", "corner", "4", "4", "3", "Boss"); + dungeon.command("dungeon", "room", "boss", "mob", "vindicator", "Keeper"); + dungeon.command("dungeon", "reward", id, "25", "7"); + dungeon.command("dungeon", "edit", "done"); dungeon.command("dungeon", "enable", id); dungeon.command("dungeon", "open", id); + } else { + DungeonAuthoringEventsNativeTest.command(f, leader, "adventure-site", "place", kind, "Boundary Site"); + shared.resetCatalog(); + } + shared.giver(); f.activate(leader); + var before = CombatExperienceNativeTest.record(f, leader); + var file = data.resolve("adventure-quests.yml"); var bytes = Files.readAllBytes(file); + Files.delete(file); Files.createDirectory(file); + try { + shared.click(leader, shared.open(leader), false); + ReskillEventsNativeTest.await(f, () -> leader.messages.contains("Quest acceptance failed; retained state is unchanged.")); + assertTrue(shared.directory.active().isEmpty()); assertEquals(before, CombatExperienceNativeTest.record(f, leader)); + } finally { Files.delete(file); Files.write(file, bytes); } + var parties = PartyLifecycleNativeTest.parties(f); var party = parties.create(leader.core.id).orElseThrow(); + shared.click(leader, shared.open(leader), true); + assertTrue(parties.disband(leader.core.id, party.id())); + ReskillEventsNativeTest.await(f, () -> leader.messages.contains("Quest acceptance refused: the character or party changed.")); + assertTrue(shared.directory.active().isEmpty()); assertEquals(before, CombatExperienceNativeTest.record(f, leader)); + f.plugin.setEnabled(false); f.awaitWorkers(); + assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row -> row.get("type").getAsString().endsWith(".adventure.quest.accepted")), + "Failed saves and actual post-save party rollback must never export accepted domain outcomes"); + // The persisted rollback remains empty after restart, not merely hidden by the current controller. + } + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); + var directory = (games.dmg.fantasystick.adventure.AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureQuests"); + assertTrue(directory.active().isEmpty()); f.plugin.setEnabled(false); f.awaitWorkers(); + assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row -> row.get("type").getAsString().endsWith(".adventure.quest.accepted"))); + } + } + + @ParameterizedTest @ValueSource(strings={"camp", "champion", "dungeon"}) + void concurrentModeCommandsPublishOnlyOneAcknowledgedFuturePolicyAndRefuseTheStaleRequest(String kind) throws Exception { + try (var shared = new CampAuditNativeFixture(data)) { + var f = shared.f; var actor = shared.actor; boolean dungeon = kind.equals("dungeon"); + var dungeons = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons"); + if (dungeon) DungeonAuthoringEventsNativeTest.command(f, actor, "dungeon", "create", "Concurrent Modes"); + else DungeonAuthoringEventsNativeTest.command(f, actor, "adventure-site", "place", kind, "Concurrent Modes"); + UUID id = dungeon ? dungeons.snapshot().dungeons().getFirst().id() : shared.directory.snapshot().sites().getFirst().id(); + var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(dungeon ? dungeons : shared.directory, "worker"); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var held = worker.submit(() -> { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new AssertionError("Owned mode barrier was not released"); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); int prior = actor.messages.size(); + try { + for (String mode : java.util.List.of("individual-only", "party-required")) + f.adminCommand.execute(actor.player, "fstickadmin", new String[]{dungeon ? "dungeon" : "adventure-site", "mode", id.toString(), mode}); + } finally { release.countDown(); } + held.get(5, TimeUnit.SECONDS); + ReskillEventsNativeTest.await(f, () -> actor.messages.size() == prior + 2); + assertEquals("individual-only", dungeon ? dungeons.find(id).orElseThrow().participationMode().value() : shared.directory.findSite(id).orElseThrow().participationMode().value()); + assertTrue(actor.messages.subList(prior, actor.messages.size()).stream().anyMatch(v -> v.contains("stale") || v.contains("acknowledged mode changed"))); + f.plugin.setEnabled(false); f.awaitWorkers(); + var rows = ReskillEventsNativeTest.records(data); + var changed = rows.stream().filter(row -> row.get("type").getAsString().endsWith(dungeon ? ".dungeon.mode_changed" : ".adventure.site.mode_changed")).toList(); + assertEquals(1, changed.size()); + assertEquals("individual-only", changed.getFirst().getAsJsonObject("data").get(dungeon ? "after_mode" : "mode_after").getAsString()); + assertEquals(1, rows.stream().filter(row -> { + if (!row.get("type").getAsString().endsWith(".admin.action")) return false; + var values = row.getAsJsonObject("data"); + return values.has("reason") && values.get("reason").getAsString().equals("stale") && values.get("outcome").getAsString().equals("refused"); + }).count(), "The losing actual mode command must resolve as a correlated refusal, not a second configuration success"); + } + } +} diff --git a/src/nativeTest/java/games/dmg/fantasystick/ParticipationEventsNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/ParticipationEventsNativeTest.java new file mode 100644 index 0000000..d88315b --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/ParticipationEventsNativeTest.java @@ -0,0 +1,151 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.JsonObject; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import games.dmg.fantasystick.adventure.AdventureQuestState; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** US-081 supported administrator / Quest Giver paths, not direct bridge invocation. */ +class ParticipationEventsNativeTest { + @TempDir Path data; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @Test void personalCampAcceptanceCapturesTheOriginalGiverAndSessionWithoutMenuDuplicates() throws Exception { + try (var fixture = new CampAuditNativeFixture(data)) { + var f = fixture.f; + fixture.camp("party-available"); fixture.giver(); fixture.resetCatalog(); f.activate(fixture.actor); + var session = ((games.dmg.fantasystick.session.EquipmentSessions) + FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions")) + .active(fixture.actor.core.id).orElseThrow(); + var menu = fixture.open(fixture.actor); + fixture.click(fixture.actor, menu, false); + ReskillEventsNativeTest.await(f, () -> fixture.actor.messages.stream().anyMatch(v -> v.startsWith("Accepted Same Camp;"))); + var quest = fixture.directory.active().getFirst(); + fixture.open(fixture.actor); fixture.open(fixture.actor); + awaitRows(f, "adventure.quest.accepted", 1); + var accepted = rows("adventure.quest.accepted").getFirst(); + var values = accepted.getAsJsonObject("data"); + assertEquals(quest.id().toString(), values.get("quest_id").getAsString()); + assertEquals(session.session().toString(), values.get("session_id").getAsString()); + assertEquals(1, rows("adventure.quest.accepted").size()); + assertTrue(values.has("giver_id"), "Camp acceptance must retain the actual original Quest Giver, not infer it on replay"); + var giver = fixture.directory.snapshot().givers().getFirst(); + assertEquals(giver.id().toString(), values.get("giver_id").getAsString()); + assertEquals("original_menu_target", values.get("giver_capture_quality").getAsString()); + assertEquals(giver.world().toString(), values.getAsJsonObject("giver_location").get("world_id").getAsString()); + } + } + @ParameterizedTest + @CsvSource({"camp,individual-only", "camp,party-available", "camp,party-required", + "champion,individual-only", "champion,party-available", "champion,party-required", + "dungeon,individual-only", "dungeon,party-available", "dungeon,party-required"}) + void everyModeAndActorExportsOnlyDurablyRevalidatedFrozenQuestAuthority(String kind, String mode) throws Exception { + try (var dungeon = new DungeonAuditNativeFixture(data)) { + var shared = dungeon.shared; var f = dungeon.f; var leader = dungeon.actor; + String siteId; + if (kind.equals("dungeon")) { + dungeon.command("dungeon", "create", "--mode", mode, "Mode Vault"); + siteId = dungeon.directory.snapshot().dungeons().getFirst().id().toString(); + dungeon.command("dungeon", "edit", "start", siteId); + dungeon.command("dungeon", "room", "create", "corner", "4", "4", "3", "Boss Room"); + dungeon.command("dungeon", "room", "boss", "mob", "vindicator", "Keeper"); + dungeon.command("dungeon", "reward", siteId, "25", "7"); + dungeon.command("dungeon", "edit", "done"); dungeon.command("dungeon", "enable", siteId); + dungeon.command("dungeon", "open", siteId); + } else { + f.adminCommand.execute(leader.player, "fstickadmin", new String[]{"adventure-site", "place", kind, + "--mode", mode, "20", "60", "Mode Site"}); + ReskillEventsNativeTest.await(f, () -> shared.directory.snapshot().sites().size() == 1); + siteId = shared.directory.snapshot().sites().getFirst().id().toString(); shared.resetCatalog(); + } + shared.giver(); f.activate(leader); + var member = new FantasyStickLifecycleNativeTest.Actor(data); + var partyless = new FantasyStickLifecycleNativeTest.Actor(data); + for (var actor : List.of(member, partyless)) { + actor.core.location.setWorld(leader.world); f.players.put(actor.core.id, actor.player); f.activate(actor); + } + var parties = PartyLifecycleNativeTest.parties(f); var 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 expected = 0; + for (var actor : List.of(partyless, member, leader)) { + for (boolean partyChoice : List.of(false, true)) { + var menu = openGiver(shared, actor); int before = actor.messages.size(); shared.click(actor, menu, partyChoice); + boolean permitted = partyChoice ? !mode.equals("individual-only") && actor == leader : !mode.equals("party-required"); + if (!permitted) { + assertTrue(shared.directory.active().isEmpty()); + assertTrue(actor.messages.size() > before); continue; + } + ReskillEventsNativeTest.await(f, () -> actor.messages.subList(before, actor.messages.size()).stream().anyMatch(v -> v.startsWith("Accepted "))); + var quest = shared.directory.active().getFirst(); expected++; awaitRows(f, "adventure.quest.accepted", expected); + var row = rows("adventure.quest.accepted").stream().filter(v -> v.getAsJsonObject("data").get("quest_id").getAsString().equals(quest.id().toString())).findFirst().orElseThrow(); + var values = row.getAsJsonObject("data"); var frozen = row.deepCopy(); + assertEquals(mode, quest.participationMode().value()); + assertEquals(partyChoice ? AdventureQuestState.UnitType.PARTY : AdventureQuestState.UnitType.SOLO, quest.unitType()); + assertEquals(partyChoice ? party.id() : actor.core.record.id(), quest.unit()); + assertEquals(partyChoice ? Set.of(leader.core.record.id(), member.core.record.id()) : Set.of(actor.core.record.id()), quest.snapshot()); + assertEquals(actor.core.id.toString(), row.get("playerid").getAsString()); + assertEquals(actor.core.record.id().toString(), row.get("characterid").getAsString()); + assertEquals(quest.catalog().toString(), values.get("catalog_id").getAsString()); + assertEquals(quest.unit().toString(), values.get("unit_id").getAsString()); + assertEquals(partyChoice ? "party" : "solo", values.get("unit_kind").getAsString()); + assertEquals(quest.snapshot().stream().sorted().map(Object::toString).toList(), + java.util.stream.StreamSupport.stream(values.getAsJsonArray("accepted_roster").spliterator(), false).map(v -> v.getAsString()).toList()); + assertEquals(shared.directory.snapshot().givers().getFirst().id().toString(), values.get("giver_id").getAsString()); + assertEquals(mode, values.get(kind.equals("dungeon") ? "accepted_mode" : "captured_mode").getAsString()); + assertEquals(quest.expiresAt().toString(), values.get(kind.equals("dungeon") ? "expires_at" : "deadline").getAsString()); + assertTrue(values.has("reward_xp_pool") && values.has("reward_coin_pool"), "Every accepted unit must retain its captured reward pools, including dungeons"); + assertEquals(quest.rewardXp().toString(), values.get("reward_xp_pool").getAsString()); + assertEquals(quest.rewardCoins().toString(), values.get("reward_coin_pool").getAsString()); + changeMode(shared, kind, siteId, mode.equals("party-required") ? "individual-only" : "party-required"); + assertEquals(quest, shared.directory.findQuest(quest.id()).orElseThrow()); openGiver(shared, actor); + assertEquals(frozen, rows("adventure.quest.accepted").stream().filter(v -> v.get("id").equals(row.get("id"))).findFirst().orElseThrow()); + assertEquals(expected, rows("adventure.quest.accepted").size()); + changeMode(shared, kind, siteId, mode); + int prior = actor.messages.size(); shared.click(actor, openGiver(shared, actor), true); + ReskillEventsNativeTest.await(f, () -> actor.messages.subList(prior, actor.messages.size()).contains("Adventure quest abandoned.")); + assertTrue(shared.directory.active().isEmpty()); + } + } + f.plugin.setEnabled(false); f.awaitWorkers(); + assertEquals(expected, rows("adventure.quest.accepted").size(), "Refused choices and repeated menus cannot emit quest success"); + } + } + // A preceding abandonment can enqueue a separate automatic menu. Observe the actual requested Giver menu, + // rather than treating whichever inventory opens first as acknowledgment of this new native interaction. + static org.bukkit.inventory.Inventory openGiver(CampAuditNativeFixture fixture, FantasyStickLifecycleNativeTest.Actor actor) throws Exception { + int prior = actor.inventories.size(); fixture.open(actor); + var giver = fixture.directory.snapshot().givers().getFirst(); + ReskillEventsNativeTest.await(fixture.f, () -> actor.inventories.subList(prior, actor.inventories.size()).stream().anyMatch(menu -> { + try { return giver.equals(FantasyStickLifecycleNativeTest.Fixture.field(menu.getHolder(), "giver")); } + catch (Exception failure) { throw new AssertionError(failure); } + })); + return actor.inventories.subList(prior, actor.inventories.size()).stream().filter(menu -> { + try { return giver.equals(FantasyStickLifecycleNativeTest.Fixture.field(menu.getHolder(), "giver")); } + catch (Exception failure) { throw new AssertionError(failure); } + }).reduce((first, last) -> last).orElseThrow(); + } + private static void changeMode(CampAuditNativeFixture fixture, String kind, String id, String mode) throws Exception { + int prior = fixture.actor.messages.size(); + fixture.f.adminCommand.execute(fixture.actor.player, "fstickadmin", new String[]{kind.equals("dungeon") ? "dungeon" : "adventure-site", "mode", id, mode}); + ReskillEventsNativeTest.await(fixture.f, () -> fixture.actor.messages.size() > prior); + } + + private List rows(String suffix) throws Exception { + return ReskillEventsNativeTest.records(data).stream() + .filter(row -> row.get("type").getAsString().endsWith("." + suffix)).toList(); + } + private void awaitRows(FantasyStickLifecycleNativeTest.Fixture f, String suffix, long count) throws Exception { + ReskillEventsNativeTest.await(f, () -> { + try { return rows(suffix).size() == count; } + catch (Exception failure) { throw new AssertionError(failure); } + }); + } +} diff --git a/src/nativeTest/java/games/dmg/fantasystick/ParticipationRegistrationEventsNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/ParticipationRegistrationEventsNativeTest.java new file mode 100644 index 0000000..2f6840a --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/ParticipationRegistrationEventsNativeTest.java @@ -0,0 +1,123 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.JsonObject; +import games.dmg.fantasystick.adventure.AdventureQuestDirectory; +import games.dmg.fantasystick.dungeon.DungeonDirectory; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class ParticipationRegistrationEventsNativeTest { + @TempDir Path data; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @ParameterizedTest + @CsvSource({"camp,circle,individual-only", "camp,rectangle,party-required", + "champion,circle,party-required", "champion,rectangle,individual-only", + "camp,circle,default", "champion,rectangle,default", "dungeon,entrance,default"}) + void acknowledgedModeVariantsCorrelateWithAdminActionsAndSurviveFailuresAndReload(String kind, String shape, String requested) throws Exception { + boolean dungeon = kind.equals("dungeon"); String root = dungeon ? "dungeon" : "adventure-site"; + String initial = requested.equals("default") ? "party-available" : requested; + String next = initial.equals("individual-only") ? "party-required" : "individual-only"; + UUID id; UUID revision; + List observed; + try (var fixture = new CampAuditNativeFixture(data)) { + var f = fixture.f; var actor = fixture.actor; + var dungeons = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons"); + var args = new ArrayList(dungeon ? List.of("dungeon", "create") : List.of("adventure-site", "place", kind)); + if (!requested.equals("default")) args.addAll(List.of("--mode", requested)); + if (!dungeon) args.addAll(shape.equals("circle") ? List.of("10") : List.of("20", "60")); + args.add("Same Mode Name"); + var console = new EquipmentAdminCommandNativeTest.Sender(); + f.adminCommand.execute(console.port, "fstickadmin", args.toArray(String[]::new)); + assertTrue(dungeons.snapshot().dungeons().isEmpty()); assertTrue(fixture.directory.snapshot().sites().isEmpty()); + doReturn(false).when(actor.player).hasPermission("fantasystick.admin"); + f.adminCommand.execute(actor.player, "fstickadmin", args.toArray(String[]::new)); + assertTrue(dungeons.snapshot().dungeons().isEmpty()); assertTrue(fixture.directory.snapshot().sites().isEmpty()); + doReturn(true).when(actor.player).hasPermission("fantasystick.admin"); + command(f, actor, args.toArray(String[]::new)); + id = dungeon ? dungeons.snapshot().dungeons().getFirst().id() : fixture.directory.snapshot().sites().getFirst().id(); + actor.core.location.setX(900); + command(f, actor, root, "mode", id.toString(), next); + revision = dungeon ? dungeons.find(id).orElseThrow().revision() : fixture.directory.findSite(id).orElseThrow().registrationRevision(); + int consoleBefore = console.messages.size(); + f.adminCommand.execute(console.port, "fstickadmin", new String[]{root, "mode", id.toString()}); + assertTrue(console.messages.subList(consoleBefore, console.messages.size()).stream().anyMatch(v -> v.contains("mode=" + next))); + command(f, actor, root, "mode", id.toString(), next); // no-op + command(f, actor, root, "mode", id.toString(), "invalid"); + var file = data.resolve(dungeon ? "dungeons.yml" : "adventure-quests.yml"); + var bytes = Files.readAllBytes(file); Files.delete(file); Files.createDirectory(file); + try { command(f, actor, root, "mode", id.toString(), initial); } + finally { Files.delete(file); Files.write(file, bytes); } + assertEquals(next, dungeon ? dungeons.find(id).orElseThrow().participationMode().value() : fixture.directory.findSite(id).orElseThrow().participationMode().value()); + assertEquals(revision, dungeon ? dungeons.find(id).orElseThrow().revision() : fixture.directory.findSite(id).orElseThrow().registrationRevision()); + ReskillEventsNativeTest.await(f, () -> { + try { return mutations(kind).size() == 2 && rows("admin.action").stream().anyMatch(row -> { + var values = row.getAsJsonObject("data"); return values.has("outcome") && values.get("outcome").getAsString().equals("failed"); + }); } catch (Exception failure) { throw new AssertionError(failure); } + }); + f.plugin.setEnabled(false); f.awaitWorkers(); observed = mutations(kind); + assertEquals(2, observed.size(), "Only actual creation and changed mode emit domain success, not reads, denials, no-ops, malformed or failed writes"); + for (var row : observed) { + var values = row.getAsJsonObject("data"); + assertEquals(id.toString(), values.get(dungeon ? "dungeon_id" : "site_id").getAsString()); + assertEquals(actor.core.id.toString(), row.get("playerid").getAsString()); + String operation = values.get("operation_id").getAsString(); + assertEquals(1, rows("admin.action").stream().filter(admin -> { + var a = admin.getAsJsonObject("data"); + return a.has("domain_operation_id") && a.get("domain_operation_id").getAsString().equals(operation) + && a.get("outcome").getAsString().equals("completed"); + }).count(), "Owner mutation must correlate to its actual acknowledged administrator action"); + if (!dungeon) { + assertEquals(kind, values.get("site_kind").getAsString()); + assertEquals(shape, values.getAsJsonObject("arena").get("shape").getAsString()); + assertEquals(shape.equals("circle") ? 10 : 20, values.getAsJsonObject("arena").get("x_blocks").getAsInt()); + assertEquals(shape.equals("circle") ? 10 : 60, values.getAsJsonObject("arena").get("z_blocks").getAsInt()); + assertEquals(12, values.get("x").getAsDouble()); + } else assertEquals(12, values.getAsJsonObject("entrance").get("x").getAsDouble()); + } + var created = observed.stream().filter(v -> v.get("type").getAsString().endsWith(dungeon ? ".created" : ".registered")).findFirst().orElseThrow().getAsJsonObject("data"); + var changed = observed.stream().filter(v -> v.get("type").getAsString().endsWith(".mode_changed")).findFirst().orElseThrow().getAsJsonObject("data"); + assertEquals(initial, created.get(dungeon ? "after_mode" : "mode_after").getAsString()); + assertEquals(initial, changed.get(dungeon ? "before_mode" : "mode_before").getAsString()); + assertEquals(next, changed.get(dungeon ? "after_mode" : "mode_after").getAsString()); + assertEquals(revision.toString(), changed.get(dungeon ? "after_revision" : "site_revision").getAsString()); + } + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); + var console = new EquipmentAdminCommandNativeTest.Sender(); + f.adminCommand.execute(console.port, "fstickadmin", new String[]{root, "mode", id.toString()}); + assertTrue(console.messages.stream().anyMatch(v -> v.contains("mode=" + next))); + var sites = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureQuests"); + var dungeons = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons"); + assertEquals(revision, dungeon ? dungeons.find(id).orElseThrow().revision() : sites.findSite(id).orElseThrow().registrationRevision()); + f.plugin.setEnabled(false); f.awaitWorkers(); + assertEquals(observed, mutations(kind), "Restart/read must not rewrite or duplicate original mode outcomes"); + } + assertAll(observed.stream().map(row -> (org.junit.jupiter.api.function.Executable) () -> { + var values = row.getAsJsonObject("data"); + assertTrue(values.has("application"), "Mode creation/update variants must explicitly declare future-only application"); + assertEquals("future_acceptances", values.get("application").getAsString()); + })); + } + private List mutations(String kind) throws Exception { + if (kind.equals("dungeon")) return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith(".dungeon.created") || row.get("type").getAsString().endsWith(".dungeon.mode_changed")).toList(); + return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith(".adventure.site.registered") || row.get("type").getAsString().endsWith(".adventure.site.mode_changed")) + .filter(row -> row.getAsJsonObject("data").get("site_kind").getAsString().equals(kind)).toList(); + } + private List rows(String suffix) throws Exception { + return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith("." + suffix)).toList(); + } + private static void command(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor, String... args) throws Exception { + DungeonAuthoringEventsNativeTest.command(f, actor, args); + } +} diff --git a/src/nativeTest/java/games/dmg/fantasystick/ParticipationRestartEventsNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/ParticipationRestartEventsNativeTest.java new file mode 100644 index 0000000..34c75a9 --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/ParticipationRestartEventsNativeTest.java @@ -0,0 +1,109 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; + +import games.dmg.fantasystick.adventure.AdventureQuestDirectory; +import games.dmg.fantasystick.adventure.AdventureQuestState; +import games.dmg.fantasystick.events.CampEventBridge; +import games.dmg.fantasystick.events.ChampionEventBridge; +import games.dmg.fantasystick.events.DungeonAuditEventBridge; +import games.dmg.fantasystick.events.LocalEventLog; +import java.nio.file.Path; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ParticipationRestartEventsNativeTest { + @TempDir Path data; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @ParameterizedTest @ValueSource(strings={"camp", "champion", "dungeon"}) + void nativeAcceptanceReplaysItsOriginalModeGiverSessionAndRewardAfterRestartAndFuturePolicyChange(String kind) throws Exception { + String owner = kind.equals("dungeon") ? "dungeonOwnerEvents" : kind.equals("camp") ? "campAuditEvents" : "championAuditEvents"; + String folder = kind.equals("dungeon") ? "dungeon-audit-event-intents" : kind + "-event-intents"; + AdventureQuestState.Quest quest; Properties original; UUID player; + try (var dungeon = new DungeonAuditNativeFixture(data)) { + var shared = dungeon.shared; var f = shared.f; var actor = shared.actor; player = actor.core.id; + if (kind.equals("dungeon")) { + dungeon.authorAndAccept(); + int prior = actor.messages.size(); shared.click(actor, ParticipationEventsNativeTest.openGiver(shared, actor), true); + ReskillEventsNativeTest.await(f, () -> actor.messages.subList(prior, actor.messages.size()).contains("Adventure quest abandoned.")); + } else { + DungeonAuthoringEventsNativeTest.command(f, actor, "adventure-site", "place", kind, "--mode", "individual-only", "Replay Site"); + shared.giver(); shared.resetCatalog(); f.activate(actor); + } + var menu = ParticipationEventsNativeTest.openGiver(shared, actor); + fenceOwner(f, owner); // Fence prior accepted owner work before deliberately rejecting this new outcome. + ((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close(); + int prior = actor.messages.size(); shared.click(actor, menu, false); + ReskillEventsNativeTest.await(f, () -> actor.messages.subList(prior, actor.messages.size()).stream().anyMatch(v -> v.startsWith("Accepted "))); + quest = shared.directory.active().getFirst(); + assertEquals("individual-only", quest.participationMode().value()); + f.plugin.setEnabled(false); f.awaitWorkers(); + var intents = WitchAuditTestSupport.intents(data.resolve(folder)).stream().filter(v -> v.getProperty("type").equals("adventure.quest.accepted")).toList(); + assertEquals(1, intents.size(), "This actual acknowledged native acceptance must retain its original durable owner intent when the writer rejects it"); + original = jsonLists(intents.getFirst()); + assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(v -> v.get("id").getAsString().equals(original.getProperty("id")))); + } + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); + var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"); + assertTrue(sessions.active(player).isEmpty()); + var directory = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureQuests"); + assertEquals(quest, directory.findQuest(quest.id()).orElseThrow()); + ReskillEventsNativeTest.await(f, () -> { + try { return ReskillEventsNativeTest.records(data).stream().anyMatch(row -> row.get("id").getAsString().equals(original.getProperty("id"))); } + catch (Exception failure) { throw new AssertionError(failure); } + }); + var messages = new java.util.ArrayList(); var main = Thread.currentThread(); + var console = FantasyStickLifecycleNativeTest.port(org.bukkit.command.CommandSender.class, (name, args) -> { + assertSame(main, Thread.currentThread(), "Worker accessed native console: " + name); + return switch (name) { + case "hasPermission" -> { assertEquals("fantasystick.admin", args[0]); yield true; } + case "getName" -> "Console"; + case "sendMessage" -> { messages.add(String.valueOf(args[0])); yield null; } + default -> throw new AssertionError("Unexpected console call " + name); + }; + }); + f.adminCommand.execute(console, "fstickadmin", new String[]{kind.equals("dungeon") ? "dungeon" : "adventure-site", "mode", quest.site().toString(), "party-required"}); + ReskillEventsNativeTest.await(f, () -> messages.stream().anyMatch(v -> v.startsWith("Updated "))); + assertEquals(quest, directory.findQuest(quest.id()).orElseThrow()); + var bridge = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, owner); + if (bridge instanceof CampEventBridge camp) camp.replay(); + else if (bridge instanceof ChampionEventBridge champion) champion.replay(); + else ((DungeonAuditEventBridge) bridge).replay(); + f.plugin.setEnabled(false); f.awaitWorkers(); + var matching = ReskillEventsNativeTest.records(data).stream().filter(v -> v.get("id").getAsString().equals(original.getProperty("id"))).toList(); + assertEquals(1, matching.size()); WitchAuditTestSupport.assertOriginal(original, matching.getFirst()); + var values = matching.getFirst().getAsJsonObject("data"); + assertEquals("individual-only", values.get(kind.equals("dungeon") ? "accepted_mode" : "captured_mode").getAsString()); + assertEquals(quest.rewardXp().toString(), values.get("reward_xp_pool").getAsString()); + assertEquals(quest.rewardCoins().toString(), values.get("reward_coin_pool").getAsString()); + assertEquals("original_menu_target", values.get("giver_capture_quality").getAsString()); + } + } + private static void fenceOwner(FantasyStickLifecycleNativeTest.Fixture f, String name) throws Exception { + // drained() is the final close stage, not an open-owner idle fence. The owner queues its sink + // handoff before this barrier; LocalEventLog completes delivery/intent cleanup on its own worker. + var bridge = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, name); + var outbox = FantasyStickLifecycleNativeTest.Fixture.field(bridge, "outbox"); + var ownerWorker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(outbox, "worker"); + ownerWorker.submit(() -> {}).get(5, TimeUnit.SECONDS); + var writer = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"); + var writerWorker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(writer, "worker"); + writerWorker.submit(() -> {}).get(5, TimeUnit.SECONDS); + } + private static Properties jsonLists(Properties source) { + var values = new Properties(); values.putAll(source); + for (var key : source.stringPropertyNames()) if (key.startsWith("data.") && source.getProperty(key).startsWith("l")) { + var array = new com.google.gson.JsonArray(); String contents = source.getProperty(key).substring(1); + if (!contents.isEmpty()) for (String value : contents.split(",")) array.add(value); + values.setProperty(key, "j" + array); + } + return values; + } +}