feat(adventure): enrich participation-mode event evidence
This commit is contained in:
1 parent
d2090bd35a
commit
18769f4003
8 files changed
+492
-5
No files matched your search
@@ -138,7 +138,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private record Context(EquipmentSessions.Active active,UUID character,UUID partyId,boolean leader,Set<UUID> 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(conteLine truncated
|
||||
@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(conteLine truncated
|
||||
private void legacyResult(games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request,String outcome,String reason){
|
||||
if(fishingEvents!=null)fishingEvents.turnInResolved(request,outcome,reason);
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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<String,Object>():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());}
|
||||
|
||||
+101
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<JsonObject> 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); }
|
||||
});
|
||||
}
|
||||
}
|
||||
+123
@@ -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<JsonObject> 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<String>(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<JsonObject> 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<JsonObject> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>(); 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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user