This commit is contained in:
1 parent
04101d4565
commit
ec46e667f1
14 files changed
+1279
-49
No files matched your search
@@ -164,6 +164,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
|
||||
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
|
||||
private games.dmg.fantasystick.events.LocalEventLog eventLog;
|
||||
private games.dmg.fantasystick.events.FishingEventBridge fishingAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> fishingAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge preferenceEvents;
|
||||
private games.dmg.fantasystick.events.EventCaptureListener eventCapture;
|
||||
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
|
||||
@@ -749,6 +751,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getVersion(), getServer().getMinecraftVersion(), getDescription().getVersion()), 1024, 134_217_728L,
|
||||
message -> getLogger().warning(message));
|
||||
eventCapture = new games.dmg.fantasystick.events.EventCaptureListener(eventLog, sessions, Clock.systemUTC());
|
||||
fishingAuditEvents = new games.dmg.fantasystick.events.FishingEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Fishing event intent failed", failure));
|
||||
adventureQuestCommands.attachFishingEvents(fishingAuditEvents);
|
||||
adventureQuestController.attachFishingEvents(fishingAuditEvents);
|
||||
fishingController.attachFishingEvents(fishingAuditEvents);
|
||||
admin.attachFishingEvents(fishingAuditEvents);
|
||||
preferenceEvents = new games.dmg.fantasystick.events.PreferenceEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), sessions, feedbackPreferences,
|
||||
failure -> getLogger().log(Level.WARNING, "Preference event publication proof failed", failure));
|
||||
@@ -872,7 +881,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
id -> presentation.snapshot().get(id),sessionAuditEvents,eventLog::emit,Clock.systemUTC());
|
||||
getServer().getPluginManager().registerEvents(messageAuditEvents,this);
|
||||
} catch (java.io.IOException failure) {
|
||||
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; switchConfigEvents = null; roleplayAvailabilityEvents = null; partyCapacityEvents = null; sheetResourcePackEvents = null; adminExperienceEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
|
||||
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; switchConfigEvents = null; roleplayAvailabilityEvents = null; partyCapacityEvents = null; sheetResourcePackEvents = null; adminExperienceEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; fishingAuditEvents = null; messageAuditEvents = null;
|
||||
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
|
||||
}
|
||||
getServer().getPluginManager().registerEvents(crafting, this);
|
||||
@@ -1013,6 +1022,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous recovery event intent drain failed", failure);
|
||||
if (replaySessionRecoveryEvents != null) replaySessionRecoveryEvents.replay();
|
||||
});
|
||||
var replayFishingEvents = fishingAuditEvents;
|
||||
fishingAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous fishing event intent drain failed", failure);
|
||||
if (replayFishingEvents != null) replayFishingEvents.replay();
|
||||
});
|
||||
recipeEvents.replay(); // Serial worker replays old intents before new discoveries are captured.
|
||||
}
|
||||
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
|
||||
@@ -1051,6 +1065,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
combatFeedback = null;
|
||||
var closingEvents = sessionEvents; sessionEvents = null;
|
||||
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
|
||||
var closingFishingAuditEvents = fishingAuditEvents; fishingAuditEvents = null;
|
||||
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
|
||||
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
|
||||
var closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
|
||||
@@ -1469,6 +1484,21 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingSessionRecoveryAuditEvents.drained());
|
||||
});
|
||||
if (closingFishingAuditEvents != null) cleanup("fishing event intent drain", () -> {
|
||||
var characterDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
var fishingDrained = closingFishing == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingFishing.drained();
|
||||
var adventureDrained = closingAdventureQuests == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingAdventureQuests.drained();
|
||||
fishingAuditDrain = java.util.concurrent.CompletableFuture.allOf(characterDrained.toCompletableFuture(),
|
||||
fishingDrained.toCompletableFuture(), adventureDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
|
||||
.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Fishing owner drain failed", failure);
|
||||
closingFishingAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingFishingAuditEvents.drained());
|
||||
});
|
||||
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
|
||||
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
|
||||
unregister(closingModes);
|
||||
|
||||
@@ -12,12 +12,14 @@ public final class AdventureQuestCommands {
|
||||
private static final String PERMISSION="fantasystick.admin";
|
||||
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestController controller;private final Consumer<Runnable> nextTick;private final AdventureLocationPresentation locations;private final AdventureAdminTeleport teleports;
|
||||
public AdventureQuestCommands(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestController controller,Consumer<Runnable> nextTick){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.nextTick=Objects.requireNonNull(nextTick);locations=new AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
|
||||
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
|
||||
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
|
||||
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fstick adventure");return;}if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fstickadmin adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fstick adventure [tracking [on|off]] or /fstickadmin adventure reroll [confirm]");return;}if(!authorized(sender))return;var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();directory.rerollToday(expected).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reset failed; the current generation is unchanged.");return;}switch(result.status()){case STALE->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again.");case APPLIED->{var catalog=result.catalog().orElseThrow();controller.publishPendingReset().whenComplete((published,publishFailure)->respond(sender,()->{if(publishFailure!=null||!published){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}));}}}));}
|
||||
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()+"; mode="+v.participationMode().value()+siteDetails(v)));}case"place"->{if(args.length<3||!(sender instanceof Player player)){usageSite(sender);return;}if(args[2].equals("fishing")){placeFishing(sender,player,args);return;}if(!(args[2].equals("camp")||args[2].equals("champion"))){usageSite(sender);return;}int start=3;var mode=AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args.length>3&&args[3].equals("--mode")){if(args.length<5){usageSite(sender);return;}try{mode=AdventureQuestState.ParticipationMode.parse(args[4]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}start=5;}var placement=placement(args,start);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena(),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+"; mode="+value.participationMode().value()+".":"Adventure site save failed; desired state is unchanged.")));}case"fishing"->{fishing(sender,args);}case"mode"->{if(args.length<3||args.length>4){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:site(id).orElse(null);if(current==null){sender.sendMessage("Adventure site not found.");return;}if(args.length==3){sender.sendMessage("Adventure site "+id+" mode="+current.participationMode().value()+".");return;}AdventureQuestState.ParticipationMode requested;try{requested=AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure site mode save failed; the acknowledged mode is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated adventure site "+id+" mode="+result.site().orElseThrow().participationMode().value()+" for future acceptances.";case UNCHANGED->"Adventure site "+id+" mode is already "+requested.value()+".";case STALE->"Adventure site mode update refused: the acknowledged mode changed; inspect and retry.";case NOT_FOUND->"Adventure site not found.";});}));}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}case"teleport"->{if(args.length!=3){usageSite(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Adventure-site teleport requires a player.");return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var site=site(id).orElse(null);if(site==null){sender.sendMessage("Adventure site not found.");return;}teleports.teleport(sender,"Adventure-site",target(site),()->site(id).map(AdventureQuestCommands::target));}default->usageSite(sender);}}
|
||||
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()+"; mode="+v.participationMode().value()+siteDetails(v)));}case"place"->{if(args.length<3||!(sender instanceof Player player)){usageSite(sender);return;}if(args[2].equals("fishing")){placeFishing(sender,player,args);return;}if(!(args[2].equals("camp")||args[2].equals("champion"))){usageSite(sender);return;}int start=3;var mode=AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args.length>3&&args[3].equals("--mode")){if(args.length<5){usageSite(sender);return;}try{mode=AdventureQuestState.ParticipationMode.parse(args[4]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}start=5;}var placement=placement(args,start);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena(),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+"; mode="+value.participationMode().value()+".":"Adventure site save failed; desired state is unchanged.")));}case"fishing"->{fishing(sender,args);}case"mode"->{if(args.length<3||args.length>4){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:site(id).orElse(null);if(current==null){sender.sendMessage("Adventure site not found.");return;}if(args.length==3){sender.sendMessage("Adventure site "+id+" mode="+current.participationMode().value()+".");return;}AdventureQuestState.ParticipationMode requested;try{requested=AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}var audit=fishingEvents;var auditActor=audit==null?null:audit.actor(sender);directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->{if(audit!=null&¤t.type()==AdventureQuestState.SiteType.FISHING&&failure==null&&result.status()==AdventureQuestDirectory.ModeStatus.APPLIED)audit.site("adventure.site.mode_changed",UUID.randomUUID(),auditActor,result.site().orElseThrow(),Map.of("mode_before",current.participationMode().value(),"mode_after",requested.value()));respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure site mode save failed; the acknowledged mode is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated adventure site "+id+" mode="+result.site().orElseThrow().participationMode().value()+" for future acceptances.";case UNCHANGED->"Adventure site "+id+" mode is already "+requested.value()+".";case STALE->"Adventure site mode update refused: the acknowledged mode changed; inspect and retry.";case NOT_FOUND->"Adventure site not found.";});});});}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}case"teleport"->{if(args.length!=3){usageSite(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Adventure-site teleport requires a player.");return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var site=site(id).orElse(null);if(site==null){sender.sendMessage("Adventure site not found.");return;}teleports.teleport(sender,"Adventure-site",target(site),()->site(id).map(AdventureQuestCommands::target));}default->usageSite(sender);}}
|
||||
public void giver(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageGiver(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageGiver(sender);return;}if(directory.snapshot().givers().isEmpty())sender.sendMessage("No Quest Givers registered.");else directory.snapshot().givers().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())));}case"place"->{if(!(sender instanceof Player player)){usageGiver(sender);return;}String name=join(args,2);var location=player.getLocation();directory.placeGiver(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Quest Giver save failed; desired state is unchanged.");return;}var world=plugin.getServer().getWorld(value.world());if(world!=null&&world.isChunkLoaded(section(value.x()),section(value.z())))controller.reconcile(world.getChunkAt(section(value.x()),section(value.z())));sender.sendMessage("Placed Quest Giver "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+".");}));}case"remove"->{if(args.length!=3){usageGiver(sender);return;}UUID id=parse(args[2]);if(id==null){usageGiver(sender);return;}directory.removeGiver(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Quest Giver removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Quest Giver not found.");else{controller.removeGiverLoaded(id);sender.sendMessage("Removed Quest Giver "+id+".");}}));}default->usageGiver(sender);}}
|
||||
public List<String> complete(CommandSender sender,String[] args){if(args.length==1){var values=new ArrayList<String>();values.add("adventure");if(sender.hasPermission(PERMISSION)){values.add("adventure-site");values.add("quest-giver");}return matching(values,args[0]);}if(args.length==2&&args[0].equals("adventure")){var values=new ArrayList<String>();if(sender instanceof Player player&&controller.trackingAvailable(player))values.add("tracking");if(sender.hasPermission(PERMISSION))values.add("reroll");return matching(values,args[1]);}if(args.length==3&&args[0].equals("adventure")&&args[1].equals("tracking")&&sender instanceof Player player&&controller.trackingAvailable(player))return matching(List.of("on","off"),args[2]);if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("fishing","list","mode","place","remove","teleport"):List.of("fishing","list","mode","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion","fishing"),args[2]);if(args.length>=4&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[2].equals("fishing"))return fishingOptionCompletion(args);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("place")&&(args[2].equals("camp")||args[2].equals("champion"))){var values=new ArrayList<>(List.of("--mode","4","16","64"));return matching(values,args[3]);}if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(modeValues(),args[4]);if(args.length==6&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(List.of("4","16","64"),args[5]);if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&whole(args[3])!=null)return matching(List.of("8","16","128"),args[4]);if(args.length==7&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode")&&whole(args[5])!=null)return matching(List.of("8","16","128"),args[6]);if(args.length==3&&args[0].equals("adventure-site")&&Set.of("fishing","mode","remove","teleport").contains(args[1]))return matching(directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("mode"))return matching(modeValues(),args[3]);if(args.length>=4&&args[0].equals("adventure-site")&&args[1].equals("fishing"))return fishingOptionCompletion(args);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
|
||||
private void placeFishing(CommandSender sender,Player player,String[] args){var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||!options.keySet().containsAll(Set.of("name","item","required","chance","xp","coins"))){usageSite(sender);return;}try{int radius=options.containsKey("radius")?bounded(options.get("radius"),4,64):directory.defaultRadius();var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var location=player.getLocation();directory.placeFishingSite(options.get("name"),options.get("item"),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),radius,percent(options.get("chance")),positive(options.get("required")),nonnegative(options.get("xp")),nonnegative(options.get("coins")),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed fishing site "+value.id()+" ("+value.displayName()+"); "+fishingDetails(value)+".":"Fishing site save failed; desired state is unchanged.")));}catch(IllegalArgumentException invalid){usageSite(sender);}}
|
||||
private void fishing(CommandSender sender,String[] args){if(args.length<3){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:directory.findSite(id).filter(value->value.type()==AdventureQuestState.SiteType.FISHING).orElse(null);if(current==null){sender.sendMessage("Fishing site not found.");return;}if(args.length==3){sender.sendMessage(current.id()+" "+current.displayName()+"; "+fishingDetails(current)+".");return;}var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||options.isEmpty()){usageSite(sender);return;}try{var before=current.fishing();var definition=new AdventureQuestState.FishingDefinition(before.itemId(),options.getOrDefault("item",before.itemName()),options.containsKey("required")?positive(options.get("required")):before.required(),options.containsKey("chance")?percent(options.get("chance")):before.chancePercent(),options.containsKey("xp")?nonnegative(options.get("xp")):before.rewardXp(),options.containsKey("coins")?nonnegative(options.get("coins")):before.rewardCoins(),options.containsKey("radius")?bounded(options.get("radius"),4,64):before.radius());var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):current.participationMode();directory.updateFishingSite(id,current,options.getOrDefault("name",current.name()),definition,mode).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Fishing site update failed; the acknowledged definition is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated fishing site "+id+"; "+fishingDetails(result.site().orElseThrow())+".";case UNCHANGED->"Fishing site "+id+" is unchanged.";case STALE->"Fishing site update refused: the acknowledged definition changed; inspect and retry.";case NOT_FOUND->"Fishing site not found.";});}));}catch(IllegalArgumentException invalid){usageSite(sender);}}
|
||||
private void placeFishing(CommandSender sender,Player player,String[] args){var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||!options.keySet().containsAll(Set.of("name","item","required","chance","xp","coins"))){usageSite(sender);return;}try{int radius=options.containsKey("radius")?bounded(options.get("radius"),4,64):directory.defaultRadius();var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var location=player.getLocation();var audit=fishingEvents;var actor=audit==null?null:audit.actor(sender);directory.placeFishingSite(options.get("name"),options.get("item"),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),radius,percent(options.get("chance")),positive(options.get("required")),nonnegative(options.get("xp")),nonnegative(options.get("coins")),mode).whenComplete((value,failure)->{if(failure==null&&audit!=null)audit.site("adventure.site.registered",value.id(),actor,value,Map.of("definition","created"));respond(sender,()->sender.sendMessage(failure==null?"Placed fishing site "+value.id()+" ("+value.displayName()+"); "+fishingDetails(value)+".":"Fishing site save failed; desired state is unchanged."));});}catch(IllegalArgumentException invalid){usageSite(sender);}}
|
||||
private void fishing(CommandSender sender,String[] args){if(args.length<3){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:directory.findSite(id).filter(value->value.type()==AdventureQuestState.SiteType.FISHING).orElse(null);if(current==null){sender.sendMessage("Fishing site not found.");return;}if(args.length==3){sender.sendMessage(current.id()+" "+current.displayName()+"; "+fishingDetails(current)+".");return;}var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||options.isEmpty()){usageSite(sender);return;}try{var before=current.fishing();var definition=new AdventureQuestState.FishingDefinition(before.itemId(),options.getOrDefault("item",before.itemName()),options.containsKey("required")?positive(options.get("required")):before.required(),options.containsKey("chance")?percent(options.get("chance")):before.chancePercent(),options.containsKey("xp")?nonnegative(options.get("xp")):before.rewardXp(),options.containsKey("coins")?nonnegative(options.get("coins")):before.rewardCoins(),options.containsKey("radius")?bounded(options.get("radius"),4,64):before.radius());var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):current.participationMode();var audit=fishingEvents;var actor=audit==null?null:audit.actor(sender);directory.updateFishingSite(id,current,options.getOrDefault("name",current.name()),definition,mode).whenComplete((result,failure)->{if(failure==null&&result.status()==AdventureQuestDirectory.FishingUpdateStatus.APPLIED&&audit!=null){var changes=new LinkedHashMap<String,Object>();for(var field:options.keySet())changes.put(field,options.get(field));audit.site("adventure.site.updated",id,actor,result.site().orElseThrow(),changes);}respond(sender,()->{if(failure!=null){sender.sendMessage("Fishing site update failed; the acknowledged definition is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated fishing site "+id+"; "+fishingDetails(result.site().orElseThrow())+".";case UNCHANGED->"Fishing site "+id+" is unchanged.";case STALE->"Fishing site update refused: the acknowledged definition changed; inspect and retry.";case NOT_FOUND->"Fishing site not found.";});});});}catch(IllegalArgumentException invalid){usageSite(sender);}}
|
||||
private static String siteDetails(AdventureQuestState.Site site){return site.fishing()==null?"":"; "+fishingDetails(site);}
|
||||
private static String fishingDetails(AdventureQuestState.Site site){var value=site.fishing();return "item=\""+value.itemName()+"\", required="+value.required()+", chance="+value.chancePercent().toPlainString()+"%, reward="+value.rewardXp()+" XP/"+value.rewardCoins()+" coins, radius="+value.radius()+", mode="+site.participationMode().value();}
|
||||
private static Map<String,String> options(String[] args,int start,Set<String> allowed){var result=new LinkedHashMap<String,String>();for(int index=start;index<args.length;index++){String raw=args[index];if(!raw.startsWith("--")||!allowed.contains(raw.substring(2))||result.containsKey(raw.substring(2))||index+1>=args.length)return null;String key=raw.substring(2),value=args[++index];if(value.startsWith("\"")){var joined=new StringBuilder(value);while(!joined.toString().endsWith("\"")&&index+1<args.length)joined.append(' ').append(args[++index]);value=joined.toString();if(value.length()<2||!value.endsWith("\""))return null;value=value.substring(1,value.length()-1);}if(value.isBlank()||value.startsWith("--"))return null;result.put(key,value);}return result;}
|
||||
|
||||
@@ -41,7 +41,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private final List<AdventureResetParticipant> resetParticipants=new ArrayList<>();private CompletableFuture<Boolean> resetWork;
|
||||
private java.util.function.BiPredicate<UUID,Location> dungeonRoomPresence=(ignored,location)->false;
|
||||
private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
|
||||
private static final class MenuHolder implements InventoryHolder {final UUID catalog;final int page;MenuHolder(UUID catalog,int page){this.catalog=catalog;this.page=page;}@Override public Inventory getInventory(){return null;}}
|
||||
private static final class MenuHolder implements InventoryHolder {final UUID catalog;final int page;final AdventureQuestState.Giver giver;MenuHolder(UUID catalog,int page,AdventureQuestState.Giver giver){this.catalog=catalog;this.page=page;this.giver=giver;}@Override public Inventory getInventory(){return null;}}
|
||||
private static final class Attempt {final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final AdventureQuestState.Arena arena;final int resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;Attempt(UUID site,UUID catalog,AdventureQuestState.SiteType type,AdventureQuestState.Arena arena,int resetSeconds){this.site=site;this.catalog=catalog;this.type=type;this.arena=arena;this.resetSeconds=resetSeconds;}}
|
||||
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random){this(plugin,directory,settings,sessions,parties,characters,progression,nextTick,clock,random,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
|
||||
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.settings=Objects.requireNonNull(settings);this.particles=Objects.requireNonNull(particles);navigation=new AdventureQuestNavigation(plugin.getServer());this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);typeKey=new NamespacedKey(plugin,"managed_npc_type");idKey=new NamespacedKey(plugin,"quest_giver_id");siteKey=new NamespacedKey(plugin,"adventure_site_id");catalogKey=new NamespacedKey(plugin,"adventure_catalog_id");attemptKey=new NamespacedKey(plugin,"adventure_attempt_id");encounterKindKey=new NamespacedKey(plugin,"adventure_encounter_kind");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");menuPageKey=new NamespacedKey(plugin,"quest_offer_page");menuFishingKey=new NamespacedKey(plugin,"quest_fishing_turn_in");}
|
||||
@@ -56,19 +56,22 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
public boolean hasActiveEncounter(){return !attempts.isEmpty()||!starting.isEmpty();}
|
||||
public AdventureQuestState.Arena arena(AdventureQuestState.Site site){return site.arenaOrDefault(settings.rules().radius());}
|
||||
public void registerFishing(FishingController fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing already attached or adventure controller closed");this.fishing=Objects.requireNonNull(fishing);}
|
||||
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
|
||||
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
|
||||
public void registerResetParticipant(AdventureResetParticipant participant){if(closed)throw new IllegalStateException("Adventure controller closed");resetParticipants.add(Objects.requireNonNull(participant));}
|
||||
public void registerDungeonRoomPresence(java.util.function.BiPredicate<UUID,Location> presence){if(closed)throw new IllegalStateException("Adventure controller closed");dungeonRoomPresence=Objects.requireNonNull(presence);}
|
||||
public CompletionStage<Boolean> publishPendingReset(){var reset=directory.snapshot().reset();if(reset==null)return CompletableFuture.completedFuture(true);if(resetWork!=null)return resetWork;for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();starting.clear();readyAt.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();refreshTrackingPlayers();var work=resetParticipants.stream().map(participant->{try{return participant.reset(reset).toCompletableFuture();}catch(RuntimeException failure){return CompletableFuture.<Void>failedFuture(failure);}}).toArray(CompletableFuture[]::new);resetWork=CompletableFuture.allOf(work).thenCompose(ignored->directory.completeReset(reset.operation())).whenComplete((ignored,failure)->resetWork=null);return resetWork;}
|
||||
private void spawnGiver(AdventureQuestState.Giver value){var world=plugin.getServer().getWorld(value.world());if(world==null||!world.isChunkLoaded(section(value.x()),section(value.z())))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
|
||||
private void configure(Villager villager,AdventureQuestState.Giver value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,GIVER);data.set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.displayName()));villager.setCustomNameVisible(true);}
|
||||
public void open(Player player){open(player,0);}
|
||||
private void open(Player player,int requestedPage){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}attemptClaim(player,context.active,pending.orElseThrow(),record);return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.min(54,Math.max(18,((count+8)/9)*9+9));var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon";case FISHING->"Fishing";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Completion trophy pool: ":"Reward: ";var lore=new ArrayList<net.kyori.adventure.text.Component>();lore.add(net.kyori.adventure.text.Component.text(kind));var fishingTerms=active!=null&&active.site().equals(siteId)&&active.fishing()!=null?active.fishing():site==null||site.fishing()==null?null:site.fishing().terms();if(fishingTerms!=null){lore.add(net.kyori.adventure.text.Component.text("Item: "+fishingTerms.itemName()+" × "+fishingTerms.required()));lore.add(net.kyori.adventure.text.Component.text("Catch chance: "+fishingTerms.chancePercent().toPlainString()+"%"));if(active!=null&&active.site().equals(siteId)&&fishing!=null)lore.add(net.kyori.adventure.text.Component.text("Held: "+fishing.quantity(context.character,fishingTerms.itemId())+" / "+fishingTerms.required()));}lore.add(net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"));if(site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON)lore.add(net.kyori.adventure.text.Component.text("Mob kills: additional variable XP, coins and native loot"));if(site!=null){lore.add(net.kyori.adventure.text.Component.text("Mode: "+site.participationMode().value()));if(active==null&&!rewardsReady&&!directory.characterClaimed(catalog.revision(),siteId,context.character)&&directory.offerOpen(siteId)&&directory.available(site))acceptanceLore(lore,site.participationMode(),context);}lore.add(net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()));lore.add(net.kyori.adventure.text.Component.text(state));meta.lore(lore);meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)inventory.setItem(45,pageItem(page-1,"Previous page"));if(page+1<pages)inventory.setItem(53,pageItem(page+1,"Next page"));}if(fishing!=null){var turnIn=new ItemStack(Material.ENDER_CHEST);var turLine truncated
|
||||
private void open(Player player,int requestedPage){open(player,requestedPage,null);}
|
||||
private void open(Player player,int requestedPage,AdventureQuestState.Giver giver){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}attemptClaim(player,context.active,pending.orElseThrow(),record);return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.min(54,Math.max(18,((count+8)/9)*9+9));var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page,giver),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon";case FISHING->"Fishing";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Completion trophy pool: ":"Reward: ";var lore=new ArrayList<net.kyori.adventure.text.Component>();lore.add(net.kyori.adventure.text.Component.text(kind));var fishingTerms=active!=null&&active.site().equals(siteId)&&active.fishing()!=null?active.fishing():site==null||site.fishing()==null?null:site.fishing().terms();if(fishingTerms!=null){lore.add(net.kyori.adventure.text.Component.text("Item: "+fishingTerms.itemName()+" × "+fishingTerms.required()));lore.add(net.kyori.adventure.text.Component.text("Catch chance: "+fishingTerms.chancePercent().toPlainString()+"%"));if(active!=null&&active.site().equals(siteId)&&fishing!=null)lore.add(net.kyori.adventure.text.Component.text("Held: "+fishing.quantity(context.character,fishingTerms.itemId())+" / "+fishingTerms.required()));}lore.add(net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"));if(site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON)lore.add(net.kyori.adventure.text.Component.text("Mob kills: additional variable XP, coins and native loot"));if(site!=null){lore.add(net.kyori.adventure.text.Component.text("Mode: "+site.participationMode().value()));if(active==null&&!rewardsReady&&!directory.characterClaimed(catalog.revision(),siteId,context.character)&&directory.offerOpen(siteId)&&directory.available(site))acceptanceLore(lore,site.participationMode(),context);}lore.add(net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()));lore.add(net.kyori.adventure.text.Component.text(state));meta.lore(lore);meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)inventory.setItem(45,pageItem(page-1,"Previous page"));if(page+1<pages)inventory.setItem(53,pageItem(page+1,"Next page"));}if(fishing!=null){var turnIn=new ILine truncated
|
||||
private ItemStack pageItem(int page,String name){var item=new ItemStack(Material.ARROW);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text(name));meta.getPersistentDataContainer().set(menuPageKey,PersistentDataType.INTEGER,page);item.setItemMeta(meta);return item;}
|
||||
private static void acceptanceLore(List<net.kyori.adventure.text.Component> lore,AdventureQuestState.ParticipationMode mode,Context context){if(mode!=AdventureQuestState.ParticipationMode.PARTY_REQUIRED)lore.add(net.kyori.adventure.text.Component.text("Left click: Accept individually"));if(mode!=AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY){if(context.partyId!=null&&context.leader)lore.add(net.kyori.adventure.text.Component.text("Right click: Accept for party"));else lore.add(net.kyori.adventure.text.Component.text("Party acceptance requires the active leader"));}}
|
||||
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).isPresent())open(event.getPlayer());}
|
||||
@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);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){fishing.open(player);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);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;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->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;directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->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();refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+quest.expiresAt()+". Follow the Quest Compass.");}));}
|
||||
@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);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;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->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;directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->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(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 claim(Player player,Context expected,UUID catalog,UUID site){directory.beginClaim(catalog,site,expected.character).whenComplete((operation,failure)->schedule(()->{if(failure!=null||operation.isEmpty()){player.sendMessage("Reward claim failed; retained rewards are unchanged.");return;}var current=context(player);if(current==null||!current.active.equals(expected.active)||!current.character.equals(operation.orElseThrow().character())){player.sendMessage("Reward claim paused: actively play the exact living character and try again.");return;}var record=sessions.currentRecord(current.active).orElse(null);if(record==null){player.sendMessage("Reward claim paused: the current character changed.");return;}attemptClaim(player,current.active,operation.orElseThrow(),record);}));}
|
||||
private void attemptClaim(Player player,EquipmentSessions.Active active,AdventureQuestState.RewardClaim operation,games.dmg.fantasystick.persistence.CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){attemptClaim(player,active,operation,award.record().orElseThrow());return;}if(award.record().isEmpty()){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}var saved=award.record().orElseThrow();directory.finishClaim(operation.id()).whenComplete((finished,finishFailure)->schedule(()->{if(finishFailure!=null||!finished){player.sendMessage("Reward claim is pending recovery; retry at a Quest Giver.");return;}var holder=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE&&holder!=null)sessions.acknowledgeProgress(holder,saved.revision().subtract(BigInteger.ONE),saved);var currentPlayer=holder==null?null:plugin.getServer().getPlayer(holder.player());var current=currentPlayer==null?null:context(currentPlayer);if(current!=null&¤t.character.equals(operation.character())){currentPlayer.sendMessage("Claimed "+operation.xp()+" fantasy XP and "+operation.coins()+" character coins.");open(currentPlayer);}}));});}
|
||||
public boolean trackingAvailable(Player player){var context=context(player);return context!=null&&directory.activeFor(context.character,context.partyId).filter(quest->quest.snapshot().contains(context.character)).isPresent();}
|
||||
|
||||
@@ -25,21 +25,26 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
private record Holder(UUID player,UUID session,UUID character,UUID nonce,int page) implements InventoryHolder{public Inventory getInventory(){return null;}}
|
||||
private final Plugin plugin;private final AdventureQuestDirectory adventures;private final FishingDirectory fishing;private final AdventureQuestSettings settings;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final Consumer<Failure> failures;
|
||||
private final NamespacedKey actionKey;private final Map<UUID,HookClaim> hooks=new HashMap<>();private final Map<UUID,UUID> menus=new HashMap<>();
|
||||
private final Map<UUID,AdventureQuestState.Giver> menuGivers=new HashMap<>();
|
||||
/** Only recently retained native catches may reappear in stale callbacks; never delete their already-owned items. */
|
||||
private final Map<UUID,ArrayDeque<UUID>> retainedHooks=new HashMap<>();
|
||||
private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
|
||||
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
|
||||
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
|
||||
public FishingController(Plugin plugin,AdventureQuestDirectory adventures,FishingDirectory fishing,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,Consumer<Failure> failures){this.plugin=Objects.requireNonNull(plugin);this.adventures=Objects.requireNonNull(adventures);this.fishing=Objects.requireNonNull(fishing);this.settings=Objects.requireNonNull(settings);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.random=Objects.requireNonNull(random);this.failures=Objects.requireNonNull(failures);actionKey=new NamespacedKey(plugin,"fishing_menu_action");}
|
||||
public void install(){for(var pending:fishing.pendingCatches())recoverCatch(pending);for(var trophy:fishing.snapshot().trophies())reconcileTrophy(trophy);for(var submission:fishing.pendingSubmissions())recoverSubmission(submission);for(var claim:fishing.snapshot().claims())recoverClaim(claim);pulseTask=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20,20);}
|
||||
private void pulse(){if(closed)return;for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId())&&!nativeUse(player,player.getInventory().getItemInMainHand()))cleanup(player);}
|
||||
@Override public java.util.concurrent.CompletionStage<Void> reset(AdventureQuestState.Reset reset){for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();menus.clear();return java.util.concurrent.CompletableFuture.completedFuture(null);}
|
||||
@Override public java.util.concurrent.CompletionStage<Void> reset(AdventureQuestState.Reset reset){for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();menus.clear();menuGivers.clear();return java.util.concurrent.CompletableFuture.completedFuture(null);}
|
||||
public boolean nativeUse(Player player,ItemStack item){if(closed||adventures.snapshot().reset()!=null||AdventureQuestNavigation.fishingQuest(item)==null)return false;var selected=sessions.selectedControl(player,item).orElse(null);if(selected==null||selected.slot()!=0)return false;var context=context(player);var quest=context==null?null:adventures.findQuest(AdventureQuestNavigation.fishingQuest(item)).orElse(null);return quest!=null&&eligible(context,quest)&&quest.fishing()!=null&&quest.site().equals(site(item));}
|
||||
public BigInteger quantity(UUID character,UUID item){return fishing.quantity(character,item);}
|
||||
public java.util.concurrent.CompletionStage<Boolean> earnTrophy(FishingState.Trophy trophy){return fishing.prepareTrophy(trophy).thenCompose(status->{if(status==FishingDirectory.TrophyPrepareStatus.REDEEMED)return java.util.concurrent.CompletableFuture.completedFuture(true);var retained=fishing.trophy(trophy.id()).orElseThrow();return adventures.complete(retained.quest(),retained.rewards().keySet()).thenCompose(ignored->fishing.commitTrophy(retained.id())).thenApply(committed->committed!=FishingDirectory.TrophyCommitStatus.NOT_FOUND);});}
|
||||
private void reconcileTrophy(FishingState.Trophy trophy){if(trophy.phase()==FishingState.TrophyPhase.EARNED)return;adventures.complete(trophy.quest(),trophy.rewards().keySet()).thenCompose(ignored->fishing.commitTrophy(trophy.id())).whenComplete((ignored,failure)->{if(failure!=null)report(trophy.id(),trophy.carrier(),"trophy-reconcile",failure);});}
|
||||
public void open(Player player){open(player,0);}
|
||||
private void open(Player player,int requestedPage){var context=context(player);if(context==null){player.sendMessage("Quest item turn-in requires an active living character.");return;}for(var pending:fishing.pendingCatches())if(pending.character().equals(context.record().id()))recoverCatch(pending);var entries=new ArrayList<ItemStack>();var quest=fishingQuest(context);if(quest!=null){var terms=quest.fishing();entries.add(item(Material.EMERALD,"Submit "+terms.itemName(),"submit",List.of("Held: "+fishing.quantity(context.record().id(),terms.itemId())+" / "+terms.required(),"Reward: "+terms.rewardXp()+" XP, "+terms.rewardCoins()+" coins",quest.unitType()==AdventureQuestState.UnitType.PARTY?"One accepted member supplies all items":"Individual submission","Beneficiaries: "+beneficiaries(context,quest).size()+" accepted character(s)")));}for(var trophy:fishing.trophies(context.record().id())){var total=trophy.total();entries.add(item(Material.PLAYER_HEAD,"Redeem "+trophy.name(),"trophy:"+trophy.id(),List.of("Origin: "+trophy.origin(),"Reward pool: "+total.xp()+" XP, "+total.coins()+" coins","Beneficiaries: "+trophy.rewards().size()+" frozen character(s)")));}var legacy=adventures.unclaimedAll(context.record().id());if(legacy.xp().signum()>0||legacy.coins().signum()>0)entries.add(item(Material.NETHER_STAR,"Claim retained adventure rewards","legacy-claim",List.of(legacy.xp()+" XP, "+legacy.coins()+" coins","Previously earned shares do not expire")));var reward=fishing.unclaimed(context.record().id());if(reward.xp().signum()>0||reward.coins().signum()>0)entries.add(item(Material.GOLD_INGOT,"Claim earned quest rewards","claim",List.of(reward.xp()+" XP, "+reward.coins()+" coins","Earned shares do not expire")));int pages=Math.max(1,(entries.size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));UUID nonce=UUID.randomUUID();menus.put(player.getUniqueId(),nonce);var inventory=plugin.getServer().createInventory(new Holder(player.getUniqueId(),context.active().session(),context.record().id(),nonce,page),54,net.kyori.adventure.text.Component.text("Turn in quest items "+(page+1)+"/"+pages));int start=page*45;for(int index=start;index<Math.min(entries.size(),start+45);index++)inventory.setItem(index-start,entries.get(index));if(entries.isEmpty())inventory.setItem(22,item(Material.BARRIER,"No eligible fishing items or rewards",null,List.of()));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","page:"+(page-1),List.of()));if(page+1<pages)inventory.setItem(53,item(Material.ARROW,"Next","page:"+(page+1),List.of()));player.openInventory(inventory);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null||closed)return;String action=event.getCurrentItem().getItemMeta().getPersistentDataContainer().get(actionKey,PersistentDataType.STRING);if(action==null)return;var context=context(player);if(context==null||!holder.player().equals(player.getUniqueId())||!holder.session().equals(context.active().session())||!holder.character().equals(context.record().id())||!menus.remove(player.getUniqueId(),holder.nonce())){player.sendMessage("Quest item action refused: the menu, character or session changed.");return;}if(action.startsWith("page:")){try{open(player,Integer.parseInt(action.substring(5)));}catch(NumberFormatException invalid){player.sendMessage("Quest item page refused.");}return;}if(action.startsWith("trophy:")){redeemTrophy(player,context,action.substring(7));return;}if(action.equals("legacy-claim")){claimLegacy(player,context);return;}if(action.equals("claim")){claim(player,context);return;}if(action.equals("submit"))submit(player,context);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)
|
||||
public void open(Player player){open(player,0,null);}
|
||||
public void open(Player player,AdventureQuestState.Giver giver){open(player,0,giver);}
|
||||
private void open(Player player,int requestedPage,AdventureQuestState.Giver giver){
|
||||
if(giver==null)menuGivers.remove(player.getUniqueId());else menuGivers.put(player.getUniqueId(),giver);var context=context(player);if(context==null){player.sendMessage("Quest item turn-in requires an active living character.");return;}for(var pending:fishing.pendingCatches())if(pending.character().equals(context.record().id()))recoverCatch(pending);var entries=new ArrayList<ItemStack>();var quest=fishingQuest(context);if(quest!=null){var terms=quest.fishing();entries.add(item(Material.EMERALD,"Submit "+terms.itemName(),"submit",List.of("Held: "+fishing.quantity(context.record().id(),terms.itemId())+" / "+terms.required(),"Reward: "+terms.rewardXp()+" XP, "+terms.rewardCoins()+" coins",quest.unitType()==AdventureQuestState.UnitType.PARTY?"One accepted member supplies all items":"Individual submission","Beneficiaries: "+beneficiaries(context,quest).size()+" accepted character(s)")));}for(var trophy:fishing.trophies(context.record().id())){var total=trophy.total();entries.add(item(Material.PLAYER_HEAD,"Redeem "+trophy.name(),"trophy:"+trophy.id(),List.of("Origin: "+trophy.origin(),"Reward pool: "+total.xp()+" XP, "+total.coins()+" coins","Beneficiaries: "+trophy.rewards().size()+" frozen character(s)")));}var legacy=adventures.unclaimedAll(context.record().id());if(legacy.xp().signum()>0||legacy.coins().signum()>0)entries.add(item(Material.NETHER_STAR,"Claim retained adventure rewards","legacy-claim",List.of(legacy.xp()+" XP, "+legacy.coins()+" coins","Previously earned shares do not expire")));var reward=fishing.unclaimed(context.record().id());if(reward.xp().signum()>0||reward.coins().signum()>0)entries.add(item(Material.GOLD_INGOT,"Claim earned quest rewards","claim",List.of(reward.xp()+" XP, "+reward.coins()+" coins","Earned shares do not expire")));int pages=Math.max(1,(entries.size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));UUID nonce=UUID.randomUUID();menus.put(player.getUniqueId(),nonce);var inventory=plugin.getServer().createInventory(new Holder(player.getUniqueId(),context.active().session(),context.record().id(),nonce,page),54,net.kyori.adventure.text.Component.text("Turn in quest items "+(page+1)+"/"+pages));int start=page*45;for(int index=start;index<Math.min(entries.size(),start+45);index++)inventory.setItem(index-start,entries.get(index));if(entries.isEmpty())inventory.setItem(22,item(Material.BARRIER,"No eligible fishing items or rewards",null,List.of()));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","page:"+(page-1),List.of()));if(page+1<pages)inventory.setItem(53,item(Material.ARROW,"Next","page:"+(page+1),List.of()));player.openInventory(inventory);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null||closed)return;String action=event.getCurrentItem().getItemMeta().getPersistentDataContainer().get(actionKey,PersistentDataType.STRING);if(action==null)return;var context=context(player);if(context==null||!holder.player().equals(player.getUniqueId())||!holder.session().equals(context.active().session())||!holder.character().equals(context.record().id())||!menus.remove(player.getUniqueId(),holder.nonce())){player.sendMessage("Quest item action refused: the menu, character or session changed.");return;}if(action.startsWith("page:")){try{open(player,Integer.parseInt(action.substring(5)),menuGivers.get(player.getUniqueId()));}catch(NumberFormatException invalid){player.sendMessage("Quest item page refused.");}return;}if(action.startsWith("trophy:")){redeemTrophy(player,context,action.substring(7));return;}if(action.equals("legacy-claim")){claimLegacy(player,context);return;}if(action.equals("claim")){claim(player,context);return;}if(action.equals("submit"))submit(player,context);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=false)
|
||||
public void fish(PlayerFishEvent event){
|
||||
if(closed)return;
|
||||
var player=event.getPlayer();
|
||||
@@ -48,52 +53,230 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
var context=context(player);
|
||||
var quest=context==null?null:adventures.findQuest(AdventureQuestNavigation.fishingQuest(item)).orElse(null);
|
||||
if(context==null||quest==null||!eligible(context,quest))return;
|
||||
var auditBridge=fishingEvents;var auditActor=auditBridge==null?null:auditBridge.actor(player);
|
||||
UUID action=UUID.randomUUID();boolean priorCancelled=event.isCancelled();
|
||||
var observation=new LinkedHashMap<String,Object>();
|
||||
observation.put("action_kind","fishing");observation.put("state",event.getState().name());
|
||||
observation.put("hook_id",event.getHook().getUniqueId().toString());
|
||||
observation.put("quest_id",quest.id().toString());observation.put("site_id",quest.site().toString());
|
||||
observation.put("candidate_catch_id",UUID.nameUUIDFromBytes((quest.id()+":"+event.getHook().getUniqueId())
|
||||
.getBytes(StandardCharsets.UTF_8)).toString());
|
||||
observation.put("fisher_location",locationMap(capture(player.getLocation())));
|
||||
observation.put("bobber_location",locationMap(capture(event.getHook().getLocation())));
|
||||
observation.put("prior_cancelled",priorCancelled);
|
||||
if(auditBridge!=null)auditBridge.transientEvent("action.observed",action,auditActor,observation);
|
||||
var outcome=new String[]{"native_continuation"};
|
||||
try {
|
||||
if(priorCancelled){outcome[0]="prior_cancelled";return;}
|
||||
switch(event.getState()){
|
||||
case FISHING->{var hook=event.getHook();hooks.put(player.getUniqueId(),new HookClaim(player.getUniqueId(),context.active().session(),context.record().id(),quest.id(),hook.getUniqueId(),sampleXp()));}
|
||||
case LURED,BITE->{} // Approach and bite are not reels; retain the original owned hook.
|
||||
case CAUGHT_ENTITY->{event.setCancelled(true);hooks.remove(player.getUniqueId());event.getHook().remove();player.sendMessage("Quest Fishing Rods cannot pull entities.");}
|
||||
case CAUGHT_ENTITY->{outcome[0]="entity_pull_refused";event.setCancelled(true);hooks.remove(player.getUniqueId());event.getHook().remove();player.sendMessage("Quest Fishing Rods cannot pull entities.");}
|
||||
case CAUGHT_FISH->{
|
||||
var claim=hooks.get(player.getUniqueId());
|
||||
if(claim==null||!claim.session().equals(context.active().session())||!claim.character().equals(context.record().id())||!claim.quest().equals(quest.id())||!claim.hook().equals(event.getHook().getUniqueId())){
|
||||
refuseCatch(event,"Quest catch refused: the cast is no longer valid; cast again.");return;
|
||||
outcome[0]="stale_or_foreign_hook";refuseCatch(event,"Quest catch refused: the cast is no longer valid; cast again.");return;
|
||||
}
|
||||
hooks.remove(player.getUniqueId(),claim); // A late callback for another hook must not consume the current cast.
|
||||
if(!(event.getCaught() instanceof Item caught)){
|
||||
refuseCatch(event,"Quest catch refused: no native fish item was caught.");return;
|
||||
outcome[0]="no_native_item";refuseCatch(event,"Quest catch refused: no native fish item was caught.");return;
|
||||
}
|
||||
if(!inside(player.getLocation(),quest)||!inside(event.getHook().getLocation(),quest)){
|
||||
refuseCatch(event,"Quest catch refused: keep yourself and the bobber inside the fishing area.");return;
|
||||
var caughtSite=adventures.findSite(quest.site()).orElse(null);
|
||||
if(caughtSite==null||!inside(player.getLocation(),quest)||!inside(event.getHook().getLocation(),quest)){
|
||||
outcome[0]="outside_captured_area";refuseCatch(event,"Quest catch refused: keep yourself and the bobber inside the fishing area.");return;
|
||||
}
|
||||
ItemStack nativeItem;
|
||||
try{nativeItem=caught.getItemStack();}catch(RuntimeException unavailable){nativeItem=null;}
|
||||
event.setExpToDrop(0);event.getHook().remove();
|
||||
BigInteger xp=claim.sampledXp();boolean awarded=random.nextDouble(100d)<quest.fishing().chancePercent().doubleValue();
|
||||
if(awarded){event.setCancelled(true);caught.remove();}
|
||||
else retain(player.getUniqueId(),claim.hook()); // Let the original native catch follow ordinary collection.
|
||||
BigInteger xp=claim.sampledXp();double sample=random.nextDouble(100d);boolean awarded=sample<quest.fishing().chancePercent().doubleValue();
|
||||
if(awarded){outcome[0]="quest_item_pending_acknowledgment";event.setCancelled(true);caught.remove();}
|
||||
else {outcome[0]="ordinary_native_retained_pending_acknowledgment";retain(player.getUniqueId(),claim.hook());} // Let the original native catch follow ordinary collection.
|
||||
UUID operation=UUID.nameUUIDFromBytes((quest.id()+":"+claim.hook()).getBytes(StandardCharsets.UTF_8));
|
||||
var plan=new FishingState.Catch(operation,context.record().id(),quest.id(),quest.site(),quest.fishing().itemId(),quest.fishing().itemName(),xp,awarded);
|
||||
var audit=new LinkedHashMap<String,String>();
|
||||
audit.put("player_id",player.getUniqueId().toString());audit.put("player_name",player.getName());
|
||||
audit.put("character_name",context.record().name());audit.put("session_id",context.active().session().toString());
|
||||
audit.put("catalog_id",quest.catalog().toString());audit.put("hook_id",claim.hook().toString());
|
||||
audit.put("radius",Integer.toString(quest.fishing().radius()));
|
||||
audit.put("chance_percent",quest.fishing().chancePercent().toPlainString());
|
||||
audit.put("sample_percent",Double.toString(sample));
|
||||
audit.put("native_item_disposition",awarded?"suppressed":"retained");
|
||||
audit.put("native_xp_disposition","suppressed");
|
||||
audit.put("native_item_type",nativeItem==null?"unavailable":nativeItem.getType().name());
|
||||
audit.put("native_item_amount",nativeItem==null?"unavailable":Integer.toString(nativeItem.getAmount()));
|
||||
audit.put("fisher_location",capture(player.getLocation()));
|
||||
audit.put("bobber_location",capture(event.getHook().getLocation()));
|
||||
audit.put("site_world_id",caughtSite.world().toString());
|
||||
audit.put("site_center_x",Double.toString(caughtSite.x()));
|
||||
audit.put("site_center_z",Double.toString(caughtSite.z()));
|
||||
audit.put("geometry_decision","both_inside_same_world_circle");
|
||||
var plan=new FishingState.Catch(operation,context.record().id(),quest.id(),quest.site(),quest.fishing().itemId(),quest.fishing().itemName(),xp,awarded,audit);
|
||||
fishing.prepareCatch(plan).whenComplete((status,failure)->{if(failure!=null){report(plan,"prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.APPLIED||status==FishingDirectory.PrepareStatus.EXISTING)recoverCatch(plan);});
|
||||
}
|
||||
case IN_GROUND,FAILED_ATTEMPT,REEL_IN->hooks.remove(player.getUniqueId());
|
||||
case IN_GROUND,FAILED_ATTEMPT,REEL_IN->{hooks.remove(player.getUniqueId());outcome[0]="no_successful_item_reel";}
|
||||
}
|
||||
} finally {
|
||||
if(auditBridge!=null){var resolved=new LinkedHashMap<String,Object>(observation);
|
||||
resolved.put("outcome",outcome[0]);resolved.put("final_cancelled",event.isCancelled());
|
||||
resolved.put("native_xp",event.getExpToDrop());
|
||||
auditBridge.transientEvent("action.resolved",action,auditActor,resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
private static String capture(Location value){
|
||||
if(value==null||value.getWorld()==null)return "unavailable";
|
||||
return value.getWorld().getUID()+","+value.getX()+","+value.getY()+","+value.getZ();
|
||||
}
|
||||
private static Map<String,Object> locationMap(String encoded){
|
||||
if(encoded==null||encoded.equals("unavailable"))return Map.of("status","unavailable");
|
||||
try{var parts=encoded.split(",",-1);if(parts.length!=4)throw new IllegalArgumentException();
|
||||
return Map.of("world_id",UUID.fromString(parts[0]).toString(),
|
||||
"x",Double.parseDouble(parts[1]),"y",Double.parseDouble(parts[2]),"z",Double.parseDouble(parts[3]));
|
||||
}catch(RuntimeException invalid){return Map.of("status","unavailable");}
|
||||
}
|
||||
private static Map<String,Object> giverLocation(AdventureQuestState.Giver giver){
|
||||
return Map.of("world_id",giver.world().toString(),"x",giver.x(),"y",giver.y(),"z",giver.z());
|
||||
}
|
||||
private void retain(UUID player,UUID hook){var recent=retainedHooks.computeIfAbsent(player,ignored->new ArrayDeque<>());recent.addLast(hook);while(recent.size()>16)recent.removeFirst();}
|
||||
private void refuseCatch(PlayerFishEvent event,String reason){event.setCancelled(true);event.setExpToDrop(0);var recent=retainedHooks.get(event.getPlayer().getUniqueId());if(event.getCaught()!=null&&(recent==null||!recent.contains(event.getHook().getUniqueId())))event.getCaught().remove();event.getPlayer().sendMessage(reason);}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void quit(org.bukkit.event.player.PlayerQuitEvent event){cleanup(event.getPlayer());retainedHooks.remove(event.getPlayer().getUniqueId());}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void quit(org.bukkit.event.player.PlayerQuitEvent event){cleanup(event.getPlayer());retainedHooks.remove(event.getPlayer().getUniqueId());menuGivers.remove(event.getPlayer().getUniqueId());}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void changed(org.bukkit.event.player.PlayerItemHeldEvent event){cleanup(event.getPlayer());}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void opened(org.bukkit.event.inventory.InventoryOpenEvent event){if(event.getPlayer() instanceof Player player&&hooks.containsKey(player.getUniqueId()))cleanup(player);}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void sneakBook(org.bukkit.event.player.PlayerInteractEvent event){if(event.getPlayer().isSneaking()&&AdventureQuestNavigation.fishingQuest(event.getItem())!=null)cleanup(event.getPlayer());}
|
||||
private void cleanup(Player player){hooks.remove(player.getUniqueId());try{var hook=player.getFishHook();if(hook!=null)hook.remove();}catch(RuntimeException ignored){}}
|
||||
private void recoverCatch(FishingState.Catch plan){characters.find(plan.character()).whenComplete((record,failure)->{if(failure!=null||record.isEmpty()){if(failure!=null)report(plan,"load",failure);return;}awardCatch(plan,record.orElseThrow());});}
|
||||
private void awardCatch(FishingState.Catch plan,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),plan.id(),plan.xp(),BigInteger.ZERO,progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(plan,"award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardCatch(plan,award.record().orElseThrow());return;}if(award.record().isEmpty()){return;}var saved=award.record().orElseThrow();fishing.completeCatch(plan.id()).whenComplete((status,completeFailure)->{if(completeFailure!=null){report(plan,"complete",completeFailure);return;}schedule(()->{var active=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(active!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(active,saved.revision().subtract(BigInteger.ONE),saved);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null&&status==FishingDirectory.CompleteStatus.APPLIED){String result=plan.itemAwarded()?"Caught "+plan.itemName()+".":"The catch yielded no quest item.";String xp=award.status()==CharacterStorage.CombatAwardStatus.AT_CAP?" Fantasy XP remained at the level cap.":award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?" Its Fantasy XP was already recorded.":" Earned "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" Fantasy XP.";player.sendMessage(result+xp);}});});});}
|
||||
private void submit(Player player,Context context){var quest=fishingQuest(context);if(quest==null){player.sendMessage("Fishing submission refused: no eligible accepted fishing quest.");return;}var beneficiaries=beneficiaries(context,quest);if(beneficiaries.isEmpty()||!beneficiaries.contains(context.record().id())){player.sendMessage("Fishing submission refused: party eligibility changed.");return;}var unit=new RewardAllocator.Unit<UUID>(quest.unit(),beneficiaries);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:beneficiaries)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID operation=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+context.record().id()).getBytes(StandardCharsets.UTF_8));var plan=new FishingState.Submission(operation,quest.id(),quest.catalog(),quest.site(),context.record().id(),quest.fishing().itemId(),quest.fishing().required(),rewards,FishingState.SubmissionPhase.PREPARED);fishing.prepareSubmission(plan).whenComplete((status,failure)->{if(failure!=null){report(operation,context.record().id(),"submit-prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.INSUFFICIENT||status==FishingDirectory.PrepareStatus.COMPLETED){schedule(()->player.sendMessage(status==FishingDirectory.PrepareStatus.INSUFFICIENT?"Fishing submission refused: the full required quantity is not held.":"Fishing submission refused: this character already completed the offered generation."));return;}commitSubmission(plan,player);});}
|
||||
private void awardCatch(FishingState.Catch plan,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),plan.id(),plan.xp(),BigInteger.ZERO,progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(plan,"award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardCatch(plan,award.record().orElseThrow());return;}if(award.record().isEmpty()){return;}var saved=award.record().orElseThrow();fishing.completeCatchWithEvidence(plan.id()).whenComplete((receipt,completeFailure)->{if(completeFailure!=null){report(plan,"complete",completeFailure);return;}if(receipt.status()==FishingDirectory.CompleteStatus.APPLIED&&fishingEvents!=null){
|
||||
var data=new LinkedHashMap<String,Object>(plan.audit());
|
||||
data.put("fisher_location",locationMap(plan.audit().get("fisher_location")));
|
||||
data.put("bobber_location",locationMap(plan.audit().get("bobber_location")));
|
||||
if(plan.audit().containsKey("radius"))data.put("radius",plan.audit().get("radius"));
|
||||
data.put("catch_id",plan.id().toString());data.put("quest_id",plan.quest().toString());
|
||||
data.put("site_id",plan.site().toString());data.put("item_id",plan.item().toString());
|
||||
data.put("item_name",plan.itemName());data.put("item_awarded",plan.itemAwarded());
|
||||
data.put("sampled_xp",plan.xp().toString());
|
||||
data.put("allocated_xp",plan.xp().toString()); // Personal catch XP, never a party split.
|
||||
data.put("stock_before",receipt.stockBefore().toString());
|
||||
data.put("stock_after",receipt.stockAfter().toString());
|
||||
data.put("award_status",award.status().name().toLowerCase(java.util.Locale.ROOT));
|
||||
if(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE)data.put("applied_xp_status","previously_applied_receipt");
|
||||
else {var applied=saved.experience().subtract(record.experience()).max(BigInteger.ZERO);
|
||||
data.put("applied_xp",applied.toString());data.put("lost_xp",plan.xp().subtract(applied).max(BigInteger.ZERO).toString());}
|
||||
fishingEvents.durable("fishing.catch_resolved",plan.id(),
|
||||
fishingEvents.actorFromCatch(plan),data);
|
||||
}schedule(()->{var active=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(active!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(active,saved.revision().subtract(BigInteger.ONE),saved);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null&&receipt.status()==FishingDirectory.CompleteStatus.APPLIED){String result=plan.itemAwarded()?"Caught "+plan.itemName()+".":"The catch yielded no quest item.";String xp=award.status()==CharacterStorage.CombatAwardStatus.AT_CAP?" Fantasy XP remained at the level cap.":award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?" Its Fantasy XP was already recorded.":" Earned "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" Fantasy XP.";player.sendMessage(result+xp);}});});});}
|
||||
private void emitSubmission(FishingState.Submission plan,games.dmg.fantasystick.events.FishingEventBridge.Actor actor,
|
||||
FishingDirectory.SubmissionReceipt receipt){
|
||||
if(fishingEvents==null)return;
|
||||
var data=new LinkedHashMap<String,Object>();
|
||||
data.put("submission_id",plan.id().toString());data.put("quest_id",plan.quest().toString());
|
||||
data.put("catalog_id",plan.catalog().toString());data.put("site_id",plan.site().toString());
|
||||
data.put("item_id",plan.item().toString());data.put("submitter_id",plan.submitter().toString());
|
||||
data.put("required",plan.quantity().toString());data.put("consumed",plan.quantity().toString());
|
||||
data.put("stock_before",receipt.stockBefore().toString());
|
||||
data.put("stock_after",receipt.stockAfter().toString());
|
||||
data.putAll(plan.audit());
|
||||
data.remove("online_roster");
|
||||
data.put("roster",plan.rewards().keySet().stream().sorted().map(UUID::toString).toList());
|
||||
if(plan.audit().containsKey("submitter_location"))
|
||||
data.put("submitter_location",locationMap(plan.audit().get("submitter_location")));
|
||||
if(plan.audit().containsKey("giver_location"))
|
||||
data.put("giver_location",locationMap(plan.audit().get("giver_location")));
|
||||
if(!plan.audit().containsKey("unit_kind"))data.put("unit_kind","unavailable_legacy_submission");
|
||||
var beneficiaries=new ArrayList<Map<String,String>>();
|
||||
var online=plan.audit().containsKey("online_roster")?Set.of(plan.audit().get("online_roster").split(",")):Set.<String>of();
|
||||
plan.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->{
|
||||
var part=new LinkedHashMap<String,String>();
|
||||
part.put("character_id",entry.getKey().toString());
|
||||
part.put("online_at_submission",plan.audit().containsKey("online_roster")
|
||||
?Boolean.toString(online.contains(entry.getKey().toString())):"unavailable");
|
||||
part.put("allocated_xp",entry.getValue().xp().toString());
|
||||
part.put("allocated_coins",entry.getValue().coins().toString());
|
||||
part.put("receipt_id",plan.id().toString());
|
||||
if(entry.getValue().xp().signum()>0||entry.getValue().coins().signum()>0)
|
||||
part.put("allocation_id",UUID.nameUUIDFromBytes(("fishing:"+plan.id()+":"+entry.getKey())
|
||||
.getBytes(StandardCharsets.UTF_8)).toString());
|
||||
else part.put("allocation_status","zero_share_completion");
|
||||
beneficiaries.add(part);
|
||||
});
|
||||
data.put("beneficiaries",beneficiaries);
|
||||
fishingEvents.durable("adventure.items_submitted",plan.id(),actor==null
|
||||
?new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null):actor,data);
|
||||
}
|
||||
private void submit(Player player,Context context){var quest=fishingQuest(context);if(quest==null){player.sendMessage("Fishing submission refused: no eligible accepted fishing quest.");return;}var beneficiaries=beneficiaries(context,quest);if(beneficiaries.isEmpty()||!beneficiaries.contains(context.record().id())){player.sendMessage("Fishing submission refused: party eligibility changed.");return;}var unit=new RewardAllocator.Unit<UUID>(quest.unit(),beneficiaries);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:beneficiaries)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID operation=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+context.record().id()).getBytes(StandardCharsets.UTF_8));var submissionAudit=new LinkedHashMap<String,String>();
|
||||
submissionAudit.put("unit_kind",quest.unitType().name().toLowerCase(java.util.Locale.ROOT));
|
||||
submissionAudit.put("unit_id",quest.unit().toString());
|
||||
var onlineRoster=beneficiaries.stream().sorted().filter(character->{
|
||||
var holder=sessions.activeCharacter(character).filter(sessions::current).orElse(null);
|
||||
var found=holder==null?null:plugin.getServer().getPlayer(holder.player());
|
||||
return found!=null&&found.isOnline();
|
||||
}).map(UUID::toString).collect(java.util.stream.Collectors.joining(","));
|
||||
if(onlineRoster.length()<=8192)submissionAudit.put("online_roster",onlineRoster);
|
||||
else submissionAudit.put("online_roster_status","unavailable_large_roster");
|
||||
submissionAudit.put("submitter_session_id",context.active().session().toString());
|
||||
submissionAudit.put("player_id",player.getUniqueId().toString());
|
||||
submissionAudit.put("player_name",player.getName());
|
||||
submissionAudit.put("character_name",context.record().name());
|
||||
submissionAudit.put("submitter_location",capture(player.getLocation()));
|
||||
var giver=menuGivers.get(player.getUniqueId());
|
||||
if(giver!=null){submissionAudit.put("giver_id",giver.id().toString());
|
||||
submissionAudit.put("giver_location",giver.world()+","+giver.x()+","+giver.y()+","+giver.z());}
|
||||
var plan=new FishingState.Submission(operation,quest.id(),quest.catalog(),quest.site(),context.record().id(),
|
||||
quest.fishing().itemId(),quest.fishing().required(),rewards,FishingState.SubmissionPhase.PREPARED,submissionAudit);
|
||||
var auditActor=fishingEvents==null?null:fishingEvents.actor(player);fishing.prepareSubmission(plan).whenComplete((status,failure)->{if(failure!=null){report(operation,context.record().id(),"submit-prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.INSUFFICIENT||status==FishingDirectory.PrepareStatus.COMPLETED){schedule(()->player.sendMessage(status==FishingDirectory.PrepareStatus.INSUFFICIENT?"Fishing submission refused: the full required quantity is not held.":"Fishing submission refused: this character already completed the offered generation."));return;}commitSubmission(plan,player,auditActor);});}
|
||||
private void recoverSubmission(FishingState.Submission plan){if(plan.phase()==FishingState.SubmissionPhase.PREPARED)fishing.commitSubmission(plan.id()).whenComplete((status,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-commit",failure);return;}if(status==FishingDirectory.CommitStatus.APPLIED||status==FishingDirectory.CommitStatus.DUPLICATE)finishRecoveredSubmission(plan);});else finishRecoveredSubmission(plan);}
|
||||
private void finishRecoveredSubmission(FishingState.Submission plan){adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((ignored,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-quest",failure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submission-recovery-finish",finishFailure);});});}
|
||||
private void commitSubmission(FishingState.Submission plan,Player player){fishing.commitSubmission(plan.id()).whenComplete((status,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submit-commit",failure);return;}if(status==FishingDirectory.CommitStatus.INSUFFICIENT){schedule(()->player.sendMessage("Fishing submission refused: stock changed before commitment."));return;}if(status==FishingDirectory.CommitStatus.NOT_FOUND)return;adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((completed,questFailure)->{if(questFailure!=null){report(plan.id(),plan.submitter(),"quest-complete",questFailure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submit-finish",finishFailure);schedule(()->{player.sendMessage("Fishing quest submitted; earned shares are ready at any Quest Giver.");open(player);});});});});}
|
||||
private void commitSubmission(FishingState.Submission plan,Player player,games.dmg.fantasystick.events.FishingEventBridge.Actor actor){fishing.commitSubmissionWithEvidence(plan.id()).whenComplete((receipt,failure)->{var status=receipt==null?null:receipt.status();if(failure!=null){report(plan.id(),plan.submitter(),"submit-commit",failure);return;}if(status==FishingDirectory.CommitStatus.INSUFFICIENT){schedule(()->player.sendMessage("Fishing submission refused: stock changed before commitment."));return;}if(status==FishingDirectory.CommitStatus.NOT_FOUND)return;if(status==FishingDirectory.CommitStatus.APPLIED)emitSubmission(plan,actor,receipt);adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((completed,questFailure)->{if(questFailure!=null){report(plan.id(),plan.submitter(),"quest-complete",questFailure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submit-finish",finishFailure);schedule(()->{player.sendMessage("Fishing quest submitted; earned shares are ready at any Quest Giver.");open(player,0,menuGivers.get(player.getUniqueId()));});});});});}
|
||||
private void redeemTrophy(Player player,Context context,String encoded){UUID trophy;try{trophy=UUID.fromString(encoded);}catch(IllegalArgumentException invalid){player.sendMessage("Trophy redemption refused: invalid authority.");return;}fishing.redeemTrophy(trophy,context.record().id()).whenComplete((status,failure)->schedule(()->{if(failure!=null){report(trophy,context.record().id(),"trophy-redeem",failure);player.sendMessage("Trophy redemption failed; the earned trophy is retained.");return;}if(status==FishingDirectory.TrophyRedeemStatus.APPLIED)player.sendMessage("Adventure trophy redeemed; frozen shares are ready for each beneficiary.");else player.sendMessage("Trophy redemption refused: the receipt is unavailable for this character.");open(player);}));}
|
||||
private void claimLegacy(Player player,Context context){adventures.beginLegacyClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"legacy-claim-prepare",failure);schedule(()->player.sendMessage("Retained adventure reward claim failed; earned shares remain."));return;}awardLegacy(player,claim.orElseThrow(),context.record());});}
|
||||
private void awardLegacy(Player player,AdventureQuestState.RewardClaim claim,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(claim.id(),claim.character(),"legacy-claim-award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardLegacy(player,claim,award.record().orElseThrow());return;}if(award.record().isEmpty())return;var saved=award.record().orElseThrow();adventures.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null){report(claim.id(),claim.character(),"legacy-claim-finish",finishFailure);return;}schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied adventure reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" retained adventure XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});});});}
|
||||
private void claim(Player player,Context context){fishing.beginClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"claim-prepare",failure);schedule(()->player.sendMessage("Fishing reward claim failed; earned shares are retained."));return;}awardClaim(player,context.active(),claim.orElseThrow(),context.record());});}
|
||||
private void recoverClaim(FishingState.Claim claim){characters.find(claim.character()).whenComplete((record,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-load",failure);return;}record.ifPresent(value->awardClaim(null,null,claim,value));});}
|
||||
private void awardClaim(Player player,EquipmentSessions.Active active,FishingState.Claim claim,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardClaim(player,active,claim,award.record().orElseThrow());return;}if(award.record().isEmpty())return;var saved=award.record().orElseThrow();fishing.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null){report(claim.id(),claim.character(),"claim-finish",finishFailure);return;}schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied fishing reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" quest XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});});});}
|
||||
private void claim(Player player,Context context){var actor=fishingEvents==null?null:fishingEvents.actor(player);var giver=menuGivers.get(player.getUniqueId());fishing.beginClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"claim-prepare",failure);schedule(()->player.sendMessage("Fishing reward claim failed; earned shares are retained."));return;}awardClaim(player,context.active(),claim.orElseThrow(),context.record(),actor,giver,false);});}
|
||||
private void recoverClaim(FishingState.Claim claim){characters.find(claim.character()).whenComplete((record,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-load",failure);return;}record.ifPresent(value->awardClaim(null,null,claim,value,null,null,true));});}
|
||||
private void awardClaim(Player player,EquipmentSessions.Active active,FishingState.Claim claim,CharacterRecord record,
|
||||
games.dmg.fantasystick.events.FishingEventBridge.Actor actor,AdventureQuestState.Giver giver,boolean recovery){
|
||||
characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{
|
||||
if(failure!=null){report(claim.id(),claim.character(),"claim-award",failure);return;}
|
||||
if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){
|
||||
awardClaim(player,active,claim,award.record().orElseThrow(),actor,giver,recovery);return;
|
||||
}
|
||||
if(award.record().isEmpty())return;
|
||||
var saved=award.record().orElseThrow();
|
||||
var before=fishing.snapshot();
|
||||
var sources=before.allocations().stream().filter(value->claim.allocations().contains(value.id()))
|
||||
.sorted(Comparator.comparing(FishingState.Allocation::id)).toList();
|
||||
fishing.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{
|
||||
if(finishFailure!=null){report(claim.id(),claim.character(),"claim-finish",finishFailure);return;}
|
||||
if(finished&&fishingEvents!=null){
|
||||
var data=new LinkedHashMap<String,Object>();
|
||||
data.put("claim_id",claim.id().toString());data.put("beneficiary_id",claim.character().toString());
|
||||
data.put("requested_xp",claim.xp().toString());data.put("requested_coins",claim.coins().toString());
|
||||
data.put("recovery",recovery);data.put("award_status",award.status().name().toLowerCase(java.util.Locale.ROOT));
|
||||
data.put("revision_before",record.revision().toString());data.put("revision_after",saved.revision().toString());
|
||||
data.put("experience_after",saved.experience().toString());data.put("coins_after",saved.coins().toString());
|
||||
if(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE)data.put("applied_status","previously_applied_receipt");
|
||||
else {
|
||||
var appliedXp=saved.experience().subtract(record.experience()).max(BigInteger.ZERO);
|
||||
var appliedCoins=saved.coins().subtract(record.coins()).max(BigInteger.ZERO);
|
||||
data.put("applied_xp",appliedXp.toString());data.put("lost_xp",claim.xp().subtract(appliedXp).max(BigInteger.ZERO).toString());
|
||||
data.put("applied_coins",appliedCoins.toString());data.put("lost_coins",claim.coins().subtract(appliedCoins).max(BigInteger.ZERO).toString());
|
||||
}
|
||||
data.put("giver_status",recovery?"recovered_without_live_giver":giver==null?"menu_origin_unverified":"entered_via_registered_giver");
|
||||
if(giver!=null){data.put("giver_id",giver.id().toString());data.put("giver_location",giverLocation(giver));}
|
||||
var provenance=new ArrayList<Map<String,String>>();
|
||||
for(var source:sources){var detail=new LinkedHashMap<String,String>();
|
||||
detail.put("allocation_id",source.id().toString());
|
||||
detail.put("source_receipt_id",source.submission().toString());
|
||||
detail.put("source_kind",before.trophyReceipts().contains(source.submission())?"trophy":"fishing");
|
||||
detail.put("catalog_id",source.catalog().toString());detail.put("site_id",source.site().toString());
|
||||
detail.put("allocated_xp",source.xp().toString());detail.put("allocated_coins",source.coins().toString());
|
||||
provenance.add(detail);
|
||||
}
|
||||
data.put("sources",provenance);
|
||||
fishingEvents.durable("adventure.reward_claimed",claim.id(),actor==null
|
||||
?new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null):actor,data);
|
||||
}
|
||||
schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied fishing reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" quest XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});
|
||||
});
|
||||
});
|
||||
}
|
||||
private AdventureQuestState.Quest fishingQuest(Context context){if(adventures.snapshot().reset()!=null)return null;return adventures.activeFor(context.record().id(),context.party()).filter(value->value.fishing()!=null&&eligible(context,value)).orElse(null);}
|
||||
private boolean eligible(Context context,AdventureQuestState.Quest quest){if(quest.unitType()==AdventureQuestState.UnitType.SOLO)return quest.unit().equals(context.record().id());return context.party()!=null&&quest.unit().equals(context.party())&&quest.snapshot().contains(context.record().id())&&context.members().contains(context.record().id());}
|
||||
private List<UUID> beneficiaries(Context context,AdventureQuestState.Quest quest){if(quest.unitType()==AdventureQuestState.UnitType.SOLO)return List.of(context.record().id());var party=parties.partyOfCharacter(context.record().id()).filter(value->value.id().equals(quest.unit())).orElse(null);return party==null?List.of():quest.snapshot().stream().filter(party.characters()::contains).distinct().sorted().toList();}
|
||||
@@ -105,5 +288,5 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
private void report(FishingState.Catch plan,String phase,Throwable failure){report(plan.id(),plan.character(),phase,failure);}
|
||||
private void report(UUID operation,UUID character,String phase,Throwable failure){try{failures.accept(new Failure(operation,character,phase,failure));}catch(RuntimeException ignored){}}
|
||||
private void schedule(Runnable task){if(closed)return;try{nextTick.accept(()->{if(!closed)task.run();});}catch(RuntimeException ignored){}}
|
||||
@Override public void close(){closed=true;if(pulseTask!=null)pulseTask.cancel();for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();retainedHooks.clear();menus.clear();}
|
||||
@Override public void close(){closed=true;if(pulseTask!=null)pulseTask.cancel();for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();retainedHooks.clear();menus.clear();menuGivers.clear();}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import java.util.concurrent.*;
|
||||
public final class FishingDirectory implements AutoCloseable {
|
||||
public enum PrepareStatus { APPLIED, EXISTING, DUPLICATE, INSUFFICIENT, COMPLETED }
|
||||
public enum CompleteStatus { APPLIED, DUPLICATE, NOT_FOUND }
|
||||
public record CatchReceipt(CompleteStatus status,BigInteger stockBefore,BigInteger stockAfter) { }
|
||||
public enum CommitStatus { APPLIED, DUPLICATE, INSUFFICIENT, NOT_FOUND }
|
||||
public record SubmissionReceipt(CommitStatus status,BigInteger stockBefore,BigInteger stockAfter) { }
|
||||
public enum TrophyPrepareStatus { APPLIED, EXISTING, REDEEMED }
|
||||
public enum TrophyRedeemStatus { APPLIED, REDEEMED, NOT_FOUND, WRONG_CARRIER }
|
||||
public enum TrophyCommitStatus { APPLIED, DUPLICATE, NOT_FOUND }
|
||||
@@ -30,9 +32,49 @@ public final class FishingDirectory implements AutoCloseable {
|
||||
public CompletableFuture<TrophyRedeemStatus> redeemTrophy(UUID id,UUID carrier){Objects.requireNonNull(id);Objects.requireNonNull(carrier);return mutate(state->{if(state.trophyReceipts().contains(id))return result(state,TrophyRedeemStatus.REDEEMED);var trophy=state.trophies().stream().filter(value->value.id().equals(id)).findFirst().orElse(null);if(trophy==null||trophy.phase()!=FishingState.TrophyPhase.EARNED)return result(state,TrophyRedeemStatus.NOT_FOUND);if(!trophy.carrier().equals(carrier))return result(state,TrophyRedeemStatus.WRONG_CARRIER);var allocations=new ArrayList<>(state.allocations());for(var entry:trophy.rewards().entrySet()){var reward=entry.getValue();if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;UUID allocation=trophyAllocationId(trophy.id(),entry.getKey());if(allocations.stream().noneMatch(value->value.id().equals(allocation)))allocations.add(new FishingState.Allocation(allocation,trophy.id(),trophy.catalog(),trophy.site(),entry.getKey(),reward.xp(),reward.coins()));}var receipts=new HashSet<>(state.trophyReceipts());receipts.add(id);return result(copyTrophies(state,state.trophies().stream().filter(value->!value.id().equals(id)).toList(),receipts,allocations),TrophyRedeemStatus.APPLIED);});}
|
||||
public List<FishingState.Catch> pendingCatches(){return snapshot.catches();}
|
||||
public CompletableFuture<PrepareStatus> prepareCatch(FishingState.Catch requested){Objects.requireNonNull(requested);return mutate(state->{if(state.catchReceipts().contains(requested.id()))return result(state,PrepareStatus.DUPLICATE);var existing=state.catches().stream().filter(value->value.id().equals(requested.id())).findFirst();if(existing.isPresent()){if(!existing.orElseThrow().equals(requested))throw new IllegalStateException("Conflicting fishing catch");return result(state,PrepareStatus.EXISTING);}var catches=new ArrayList<>(state.catches());catches.add(requested);return result(copy(state,state.inventory(),catches,state.catchReceipts(),state.submissions(),state.completions(),state.allocations(),state.claims()),PrepareStatus.APPLIED);});}
|
||||
public CompletableFuture<CompleteStatus> completeCatch(UUID operation){return mutate(state->{if(state.catchReceipts().contains(operation))return result(state,CompleteStatus.DUPLICATE);var caught=state.catches().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(caught==null)return result(state,CompleteStatus.NOT_FOUND);var inventory=new ArrayList<>(state.inventory());if(caught.itemAwarded()){var existing=inventory.stream().filter(value->value.character().equals(caught.character())&&value.item().equals(caught.item())).findFirst().orElse(null);if(existing==null)inventory.add(new FishingState.ItemStack(caught.character(),caught.item(),caught.site(),caught.itemName(),BigInteger.ONE));else{var updated=new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),caught.itemName(),existing.amount().add(BigInteger.ONE));inventory.replaceAll(value->value==existing?updated:value);}}var receipts=new HashSet<>(state.catchReceipts());receipts.add(operation);var catches=state.catches().stream().filter(value->!value.id().equals(operation)).toList();return result(copy(state,inventory,catches,receipts,state.submissions(),state.completions(),state.allocations(),state.claims()),CompleteStatus.APPLIED);});}
|
||||
public CompletableFuture<CompleteStatus> completeCatch(UUID operation){return completeCatchWithEvidence(operation).thenApply(CatchReceipt::status);}
|
||||
public CompletableFuture<CatchReceipt> completeCatchWithEvidence(UUID operation){return mutate(state->{
|
||||
if(state.catchReceipts().contains(operation))return result(state,new CatchReceipt(CompleteStatus.DUPLICATE,null,null));
|
||||
var caught=state.catches().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);
|
||||
if(caught==null)return result(state,new CatchReceipt(CompleteStatus.NOT_FOUND,null,null));
|
||||
var before=quantity(state,caught.character(),caught.item());
|
||||
var inventory=new ArrayList<>(state.inventory());
|
||||
if(caught.itemAwarded()){
|
||||
var existing=inventory.stream().filter(value->value.character().equals(caught.character())&&value.item().equals(caught.item())).findFirst().orElse(null);
|
||||
if(existing==null)inventory.add(new FishingState.ItemStack(caught.character(),caught.item(),caught.site(),caught.itemName(),BigInteger.ONE));
|
||||
else {var updated=new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),caught.itemName(),existing.amount().add(BigInteger.ONE));inventory.replaceAll(value->value==existing?updated:value);}
|
||||
}
|
||||
var receipts=new HashSet<>(state.catchReceipts());receipts.add(operation);
|
||||
var catches=state.catches().stream().filter(value->!value.id().equals(operation)).toList();
|
||||
return result(copy(state,inventory,catches,receipts,state.submissions(),state.completions(),state.allocations(),state.claims()),
|
||||
new CatchReceipt(CompleteStatus.APPLIED,before,before.add(caught.itemAwarded()?BigInteger.ONE:BigInteger.ZERO)));
|
||||
});}
|
||||
public CompletableFuture<PrepareStatus> prepareSubmission(FishingState.Submission requested){Objects.requireNonNull(requested);if(requested.phase()!=FishingState.SubmissionPhase.PREPARED)throw new IllegalArgumentException("Submission must begin prepared");return mutate(state->{var existing=state.submissions().stream().filter(value->value.id().equals(requested.id())).findFirst();if(existing.isPresent()){if(!sameSubmission(existing.orElseThrow(),requested))throw new IllegalStateException("Conflicting fishing submission");return result(state,PrepareStatus.EXISTING);}if(state.submissions().stream().anyMatch(value->value.quest().equals(requested.quest())))return result(state,PrepareStatus.COMPLETED);boolean completed=requested.rewards().keySet().stream().anyMatch(character->state.completions().contains(new FishingState.Completion(requested.catalog(),requested.site(),character)));if(completed)return result(state,PrepareStatus.COMPLETED);if(quantity(state,requested.submitter(),requested.item()).compareTo(requested.quantity())<0)return result(state,PrepareStatus.INSUFFICIENT);var submissions=new ArrayList<>(state.submissions());submissions.add(requested);return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),submissions,state.completions(),state.allocations(),state.claims()),PrepareStatus.APPLIED);});}
|
||||
public CompletableFuture<CommitStatus> commitSubmission(UUID operation){return mutate(state->{var submission=state.submissions().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(submission==null)return result(state,CommitStatus.NOT_FOUND);if(submission.phase()==FishingState.SubmissionPhase.COMMITTED)return result(state,CommitStatus.DUPLICATE);if(quantity(state,submission.submitter(),submission.item()).compareTo(submission.quantity())<0)return result(state,CommitStatus.INSUFFICIENT);var inventory=new ArrayList<>(state.inventory());var existing=inventory.stream().filter(value->value.character().equals(submission.submitter())&&value.item().equals(submission.item())).findFirst().orElseThrow();var remaining=existing.amount().subtract(submission.quantity());if(remaining.signum()==0)inventory.remove(existing);else inventory.replaceAll(value->value==existing?new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),existing.name(),remaining):value);var completions=new HashSet<>(state.completions());var allocations=new ArrayList<>(state.allocations());for(var entry:submission.rewards().entrySet()){completions.add(new FishingState.Completion(submission.catalog(),submission.site(),entry.getKey()));var reward=entry.getValue();if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;UUID id=allocationId(submission.id(),entry.getKey());allocations.add(new FishingState.Allocation(id,submission.id(),submission.catalog(),submission.site(),entry.getKey(),reward.xp(),reward.coins()));}var submissions=state.submissions().stream().map(value->value.id().equals(operation)?value.committed():value).toList();return result(copy(state,inventory,state.catches(),state.catchReceipts(),submissions,completions,allocations,state.claims()),CommitStatus.APPLIED);});}
|
||||
public CompletableFuture<CommitStatus> commitSubmission(UUID operation){return commitSubmissionWithEvidence(operation).thenApply(SubmissionReceipt::status);}
|
||||
public CompletableFuture<SubmissionReceipt> commitSubmissionWithEvidence(UUID operation){return mutate(state->{
|
||||
var submission=state.submissions().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);
|
||||
if(submission==null)return result(state,new SubmissionReceipt(CommitStatus.NOT_FOUND,null,null));
|
||||
if(submission.phase()==FishingState.SubmissionPhase.COMMITTED)
|
||||
return result(state,new SubmissionReceipt(CommitStatus.DUPLICATE,null,null));
|
||||
var before=quantity(state,submission.submitter(),submission.item());
|
||||
if(before.compareTo(submission.quantity())<0)
|
||||
return result(state,new SubmissionReceipt(CommitStatus.INSUFFICIENT,before,before));
|
||||
var inventory=new ArrayList<>(state.inventory());
|
||||
var existing=inventory.stream().filter(value->value.character().equals(submission.submitter())&&value.item().equals(submission.item())).findFirst().orElseThrow();
|
||||
var remaining=before.subtract(submission.quantity());
|
||||
if(remaining.signum()==0)inventory.remove(existing);
|
||||
else inventory.replaceAll(value->value==existing?new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),existing.name(),remaining):value);
|
||||
var completions=new HashSet<>(state.completions());var allocations=new ArrayList<>(state.allocations());
|
||||
for(var entry:submission.rewards().entrySet()){
|
||||
completions.add(new FishingState.Completion(submission.catalog(),submission.site(),entry.getKey()));
|
||||
var reward=entry.getValue();if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;
|
||||
allocations.add(new FishingState.Allocation(allocationId(submission.id(),entry.getKey()),submission.id(),
|
||||
submission.catalog(),submission.site(),entry.getKey(),reward.xp(),reward.coins()));
|
||||
}
|
||||
var submissions=state.submissions().stream().map(value->value.id().equals(operation)?value.committed():value).toList();
|
||||
return result(copy(state,inventory,state.catches(),state.catchReceipts(),submissions,completions,allocations,state.claims()),
|
||||
new SubmissionReceipt(CommitStatus.APPLIED,before,remaining));
|
||||
});}
|
||||
public CompletableFuture<Boolean> finishSubmission(UUID operation){return mutate(state->{var found=state.submissions().stream().filter(value->value.id().equals(operation)&&value.phase()==FishingState.SubmissionPhase.COMMITTED).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),state.submissions().stream().filter(value->!value.id().equals(operation)).toList(),state.completions(),state.allocations(),state.claims()),true);});}
|
||||
public List<FishingState.Submission> pendingSubmissions(){return snapshot.submissions();}
|
||||
public boolean completed(UUID catalog,UUID site,UUID character){return snapshot.completions().contains(new FishingState.Completion(catalog,site,character));}
|
||||
|
||||
@@ -23,14 +23,36 @@ public record FishingState(List<ItemStack> inventory,List<Trophy> trophies,Set<U
|
||||
public Reward total(){return rewards.values().stream().reduce(Reward.empty(),Reward::add);}
|
||||
public Trophy earned(){return phase==TrophyPhase.EARNED?this:new Trophy(id,type,carrier,catalog,site,quest,source,name,origin,rewards,TrophyPhase.EARNED);}
|
||||
}
|
||||
public record Catch(UUID id,UUID character,UUID quest,UUID site,UUID item,String itemName,BigInteger xp,boolean itemAwarded) {
|
||||
public Catch {Objects.requireNonNull(id);Objects.requireNonNull(character);Objects.requireNonNull(quest);Objects.requireNonNull(site);Objects.requireNonNull(item);itemName=validName(itemName);Objects.requireNonNull(xp);if(itemName.isBlank()||xp.signum()<0||xp.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException("Invalid fishing catch");}
|
||||
public record Catch(UUID id,UUID character,UUID quest,UUID site,UUID item,String itemName,BigInteger xp,
|
||||
boolean itemAwarded,Map<String,String> audit) {
|
||||
public Catch {
|
||||
Objects.requireNonNull(id);Objects.requireNonNull(character);Objects.requireNonNull(quest);
|
||||
Objects.requireNonNull(site);Objects.requireNonNull(item);itemName=validName(itemName);
|
||||
Objects.requireNonNull(xp);audit=Map.copyOf(audit);
|
||||
if(itemName.isBlank()||xp.signum()<0||xp.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0
|
||||
||audit.size()>32||audit.entrySet().stream().anyMatch(entry->entry.getKey().length()>64
|
||||
||entry.getValue().length()>256))throw new IllegalArgumentException("Invalid fishing catch");
|
||||
}
|
||||
public Catch(UUID id,UUID character,UUID quest,UUID site,UUID item,String itemName,BigInteger xp,
|
||||
boolean itemAwarded){this(id,character,quest,site,item,itemName,xp,itemAwarded,Map.of());}
|
||||
}
|
||||
public enum SubmissionPhase { PREPARED, COMMITTED }
|
||||
public record Submission(UUID id,UUID quest,UUID catalog,UUID site,UUID submitter,UUID item,BigInteger quantity,
|
||||
Map<UUID,Reward> rewards,SubmissionPhase phase) {
|
||||
public Submission {Objects.requireNonNull(id);Objects.requireNonNull(quest);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(submitter);Objects.requireNonNull(item);Objects.requireNonNull(quantity);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);if(quantity.signum()<=0||rewards.isEmpty()||!rewards.containsKey(submitter)||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null))throw new IllegalArgumentException("Invalid fishing submission");}
|
||||
public Submission committed(){return new Submission(id,quest,catalog,site,submitter,item,quantity,rewards,SubmissionPhase.COMMITTED);}
|
||||
Map<UUID,Reward> rewards,SubmissionPhase phase,Map<String,String> audit) {
|
||||
public Submission {
|
||||
Objects.requireNonNull(id);Objects.requireNonNull(quest);Objects.requireNonNull(catalog);
|
||||
Objects.requireNonNull(site);Objects.requireNonNull(submitter);Objects.requireNonNull(item);
|
||||
Objects.requireNonNull(quantity);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);audit=Map.copyOf(audit);
|
||||
if(quantity.signum()<=0||rewards.isEmpty()||!rewards.containsKey(submitter)
|
||||
||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null)
|
||||
||audit.size()>16||audit.entrySet().stream().anyMatch(entry->entry.getKey().length()>64
|
||||
||entry.getValue().length()>8192))throw new IllegalArgumentException("Invalid fishing submission");
|
||||
}
|
||||
public Submission(UUID id,UUID quest,UUID catalog,UUID site,UUID submitter,UUID item,BigInteger quantity,
|
||||
Map<UUID,Reward> rewards,SubmissionPhase phase){
|
||||
this(id,quest,catalog,site,submitter,item,quantity,rewards,phase,Map.of());
|
||||
}
|
||||
public Submission committed(){return new Submission(id,quest,catalog,site,submitter,item,quantity,rewards,SubmissionPhase.COMMITTED,audit);}
|
||||
}
|
||||
public record Completion(UUID catalog,UUID site,UUID character) {public Completion {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);}}
|
||||
public record Allocation(UUID id,UUID submission,UUID catalog,UUID site,UUID character,BigInteger xp,BigInteger coins) {
|
||||
|
||||
@@ -134,6 +134,8 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
else if (value instanceof java.util.List<?> list && list.stream().allMatch(item -> item instanceof String text
|
||||
&& (text.equals("hotbar") || text.equals("armor"))))
|
||||
fields.setProperty(key, "l" + String.join(",", list.stream().map(String.class::cast).toList()));
|
||||
else if (value instanceof java.util.List<?> list)
|
||||
fields.setProperty(key, "j" + new com.google.gson.Gson().toJson(list));
|
||||
else throw new IllegalArgumentException("Unsupported feature intent value");
|
||||
}
|
||||
|
||||
@@ -188,6 +190,11 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
throw new IOException("Invalid slot class");
|
||||
yield list;
|
||||
}
|
||||
case 'j' -> {
|
||||
var parsed = com.google.gson.JsonParser.parseString(value.substring(1));
|
||||
if (!parsed.isJsonArray()) throw new IOException("Invalid event collection");
|
||||
yield decodeCollection(parsed, 0);
|
||||
}
|
||||
default -> throw new IOException("Invalid feature event value type");
|
||||
};
|
||||
var name = key.substring(5);
|
||||
@@ -207,6 +214,30 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
} catch (RuntimeException failure) { throw new IOException("Invalid character event intent", failure); }
|
||||
}
|
||||
|
||||
private static Object decodeCollection(com.google.gson.JsonElement value, int depth) throws IOException {
|
||||
if (depth > 8 || value.isJsonNull()) throw new IOException("Unsafe event collection");
|
||||
if (value.isJsonArray()) {
|
||||
var list = new java.util.ArrayList<>();
|
||||
for (var item : value.getAsJsonArray()) list.add(decodeCollection(item, depth + 1));
|
||||
return java.util.List.copyOf(list);
|
||||
}
|
||||
if (value.isJsonObject()) {
|
||||
var map = new LinkedHashMap<String, Object>();
|
||||
for (var entry : value.getAsJsonObject().entrySet())
|
||||
map.put(entry.getKey(), decodeCollection(entry.getValue(), depth + 1));
|
||||
return java.util.Map.copyOf(map);
|
||||
}
|
||||
var primitive = value.getAsJsonPrimitive();
|
||||
if (primitive.isBoolean()) return primitive.getAsBoolean();
|
||||
if (primitive.isString()) return primitive.getAsString();
|
||||
if (primitive.isNumber()) {
|
||||
var number = primitive.getAsDouble();
|
||||
if (!Double.isFinite(number)) throw new IOException("Non-finite event collection number");
|
||||
return number;
|
||||
}
|
||||
throw new IOException("Unsupported event collection value");
|
||||
}
|
||||
|
||||
private void publish(EventInput event) {
|
||||
try { sink.apply(event).whenComplete((delivery, failure) -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.adventure.FishingState;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.BlockCommandSender;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Acknowledged fishing outcomes and native fishing attempts, independent of chat feedback. */
|
||||
public final class FishingEventBridge implements AutoCloseable {
|
||||
public record Actor(String kind, EventInput.Context context) { }
|
||||
private static final Set<String> DURABLE = Set.of("adventure.site.registered", "adventure.site.updated", "adventure.site.mode_changed",
|
||||
"adventure.quest.accepted", "config.changed", "fishing.catch_resolved",
|
||||
"adventure.items_submitted", "adventure.reward_claimed");
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final EquipmentSessions sessions;
|
||||
private final FeatureEventOutbox outbox;
|
||||
|
||||
public FishingEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
|
||||
Path data, EquipmentSessions sessions, Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
outbox = new FeatureEventOutbox(data, "fishing-event-intents", DURABLE, sink, failures);
|
||||
}
|
||||
|
||||
public Actor actor(CommandSender sender) {
|
||||
try {
|
||||
if (!(sender instanceof Player player))
|
||||
return new Actor(sender instanceof BlockCommandSender ? "command_block" : "console", null);
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return new Actor("player", new EventInput.Context(player.getUniqueId(), player.getName(),
|
||||
record == null ? null : record.id(), record == null ? null : record.name(),
|
||||
record == null ? null : active.session(), safeLocation(player)));
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public void site(String type, UUID operation, Actor actor, AdventureQuestState.Site site,
|
||||
Map<String, Object> changed) {
|
||||
if (actor == null || site == null || site.fishing() == null) return;
|
||||
var terms = site.fishing();
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("actor_kind", actor.kind());
|
||||
values.put("site_kind", "fishing");
|
||||
values.put("site_id", site.id().toString());
|
||||
values.put("site_name", site.name());
|
||||
values.put("item_id", terms.itemId().toString());
|
||||
values.put("item_name", terms.itemName());
|
||||
values.put("required", terms.required().toString());
|
||||
values.put("chance_percent", terms.chancePercent().toPlainString());
|
||||
values.put("reward_xp", terms.rewardXp().toString());
|
||||
values.put("reward_coins", terms.rewardCoins().toString());
|
||||
values.put("radius", terms.radius());
|
||||
values.put("mode", site.participationMode().value());
|
||||
values.put("world_id", site.world().toString());
|
||||
values.put("x", site.x()); values.put("y", site.y()); values.put("z", site.z());
|
||||
values.put("changed", changed);
|
||||
durable(type, operation, actor, values);
|
||||
}
|
||||
|
||||
public void accepted(UUID operation, Actor actor, AdventureQuestState.Quest quest) {
|
||||
if (actor == null || quest.fishing() == null) return;
|
||||
var terms = quest.fishing();
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("site_kind", "fishing"); values.put("quest_id", quest.id().toString());
|
||||
values.put("catalog_id", quest.catalog().toString()); values.put("site_id", quest.site().toString());
|
||||
values.put("unit_kind", quest.unitType().name().toLowerCase(java.util.Locale.ROOT));
|
||||
values.put("unit_id", quest.unit().toString());
|
||||
values.put("item_id", terms.itemId().toString()); values.put("item_name", terms.itemName());
|
||||
values.put("required", terms.required().toString());
|
||||
values.put("chance_percent", terms.chancePercent().toPlainString());
|
||||
values.put("radius", terms.radius());
|
||||
values.put("reward_xp", terms.rewardXp().toString());
|
||||
values.put("reward_coins", terms.rewardCoins().toString());
|
||||
values.put("roster", quest.snapshot().stream().sorted().map(UUID::toString).toList());
|
||||
durable("adventure.quest.accepted", operation, actor, values);
|
||||
}
|
||||
|
||||
public void configRead(Actor actor, String key, String effective, String defaultValue) {
|
||||
if (actor == null) return;
|
||||
var data = config(actor, key, defaultValue);
|
||||
data.put("effective", effective); data.put("outcome", "acknowledged_read");
|
||||
transientEvent("config.read", UUID.randomUUID(), actor, data);
|
||||
}
|
||||
|
||||
public void configChanged(Actor actor, UUID operation, String key, String before,
|
||||
String effective, String defaultValue) {
|
||||
if (actor == null) return;
|
||||
var data = config(actor, key, defaultValue);
|
||||
data.put("before", before); data.put("after", effective); data.put("effective", effective);
|
||||
data.put("outcome", "acknowledged_future_casts");
|
||||
durable("config.changed", operation, actor, data);
|
||||
}
|
||||
|
||||
private static LinkedHashMap<String, Object> config(Actor actor, String key, String defaultValue) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", actor.kind()); data.put("key", key); data.put("default", defaultValue);
|
||||
data.put("application_scope", "future_fishing_casts");
|
||||
if (actor.context() == null) data.put("location_status", "not_applicable");
|
||||
else if (actor.context().location() == null) data.put("location_status", "unavailable");
|
||||
return data;
|
||||
}
|
||||
|
||||
public Actor actorFromCatch(FishingState.Catch catchReceipt) {
|
||||
var audit = catchReceipt.audit();
|
||||
try {
|
||||
return new Actor("player", new EventInput.Context(UUID.fromString(audit.get("player_id")),
|
||||
audit.get("player_name"), catchReceipt.character(), audit.get("character_name"),
|
||||
UUID.fromString(audit.get("session_id")), null));
|
||||
} catch (RuntimeException unavailable) { return new Actor("system", null); }
|
||||
}
|
||||
|
||||
public void durable(String type, UUID operation, Actor actor, Map<String, Object> data) {
|
||||
if (actor == null) return;
|
||||
try {
|
||||
String collection = data.get("beneficiaries") instanceof List<?> ? "beneficiaries"
|
||||
: data.get("sources") instanceof List<?> ? "sources"
|
||||
: data.get("roster") instanceof List<?> ? "roster" : null;
|
||||
if (collection == null || ((List<?>)data.get(collection)).size() <= 20) {
|
||||
outbox.accepted(input(type, operation, actor, data));
|
||||
return;
|
||||
}
|
||||
var entries = (List<?>) data.get(collection);
|
||||
int count = (entries.size() + 19) / 20;
|
||||
for (int index = 0; index < count; index++) {
|
||||
int from = index * 20, to = Math.min(from + 20, entries.size());
|
||||
var part = new LinkedHashMap<String, Object>(data);
|
||||
part.put(collection, entries.subList(from, to));
|
||||
if (!collection.equals("roster") && data.get("roster") instanceof List<?> roster)
|
||||
part.put("roster", roster.size() == entries.size() ? roster.subList(from, to) : List.of());
|
||||
part.put("linked_collection", collection);
|
||||
part.put("collection_total", entries.size());
|
||||
part.put("part_index", index);
|
||||
part.put("part_count", count);
|
||||
outbox.accepted(input(type, operation, actor, part, index));
|
||||
}
|
||||
} catch (RuntimeException ignored) { /* Audit availability cannot change acknowledged fishing. */ }
|
||||
}
|
||||
|
||||
public void transientEvent(String type, UUID operation, Actor actor, Map<String, Object> data) {
|
||||
if (actor == null) return;
|
||||
try { sink.apply(input(type, operation, actor, data)); }
|
||||
catch (RuntimeException ignored) { /* Observation cannot change native fishing. */ }
|
||||
}
|
||||
|
||||
private EventInput input(String type, UUID operation, Actor actor, Map<String, Object> data) {
|
||||
return input(type, operation, actor, data, -1);
|
||||
}
|
||||
|
||||
private EventInput input(String type, UUID operation, Actor actor, Map<String, Object> data, int part) {
|
||||
var values = new LinkedHashMap<String, Object>(data);
|
||||
values.put("operation_id", operation.toString());
|
||||
var identity = "fantasystick:fishing:" + operation + ':' + type + (part < 0 ? "" : ":part:" + part);
|
||||
return new EventInput(UUID.nameUUIDFromBytes(identity.getBytes(StandardCharsets.UTF_8)), operation,
|
||||
clock.instant(), type, actor.context(), values);
|
||||
}
|
||||
|
||||
private EventInput.Location safeLocation(Player player) {
|
||||
try { return location(player.getLocation()); }
|
||||
catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
if (value == null || value.getWorld() == null) return null;
|
||||
var world = value.getWorld();
|
||||
if (world.getName() == null) return null;
|
||||
return new EventInput.Location(world.getUID(), world.getName(), value.getX(), value.getY(), value.getZ(),
|
||||
clock.instant(), "exact");
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -22,9 +22,9 @@ public final class YamlFishingRepository {
|
||||
var inventory=new ArrayList<FishingState.ItemStack>();for(Object value:list(root.get("inventory"))){var row=map(value);inventory.add(new FishingState.ItemStack(uuid(row,"character"),uuid(row,"item"),uuid(row,"site"),text(row.get("name")),number(row.get("amount"))));}
|
||||
var trophies=new ArrayList<FishingState.Trophy>();if(schema>=2)for(Object value:list(root.get("trophies"))){var row=map(value);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(Object rewardValue:list(row.get("rewards"))){var reward=map(rewardValue);if(rewards.put(uuid(reward,"character"),new FishingState.Reward(number(reward.get("xp")),number(reward.get("coins"))))!=null)throw new IllegalArgumentException();}trophies.add(new FishingState.Trophy(uuid(row,"id"),FishingState.TrophyType.valueOf(text(row.get("type"))),uuid(row,"carrier"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"quest"),uuid(row,"source"),text(row.get("name")),text(row.get("origin")),rewards,FishingState.TrophyPhase.valueOf(text(row.get("phase")))));}
|
||||
var trophyReceipts=schema>=2?uuids(root.get("trophy-receipts")):Set.<UUID>of();
|
||||
var catches=new ArrayList<FishingState.Catch>();for(Object value:list(root.get("catches"))){var row=map(value);catches.add(new FishingState.Catch(uuid(row,"id"),uuid(row,"character"),uuid(row,"quest"),uuid(row,"site"),uuid(row,"item"),text(row.get("item-name")),number(row.get("xp")),bool(row.get("item-awarded"))));}
|
||||
var catches=new ArrayList<FishingState.Catch>();for(Object value:list(root.get("catches"))){var row=map(value);catches.add(new FishingState.Catch(uuid(row,"id"),uuid(row,"character"),uuid(row,"quest"),uuid(row,"site"),uuid(row,"item"),text(row.get("item-name")),number(row.get("xp")),bool(row.get("item-awarded")),strings(row.get("audit"))));}
|
||||
var receipts=uuids(root.get("catch-receipts"));
|
||||
var submissions=new ArrayList<FishingState.Submission>();for(Object value:list(root.get("submissions"))){var row=map(value);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(Object rewardValue:list(row.get("rewards"))){var reward=map(rewardValue);if(rewards.put(uuid(reward,"character"),new FishingState.Reward(number(reward.get("xp")),number(reward.get("coins"))))!=null)throw new IllegalArgumentException();}submissions.add(new FishingState.Submission(uuid(row,"id"),uuid(row,"quest"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"submitter"),uuid(row,"item"),number(row.get("quantity")),rewards,FishingState.SubmissionPhase.valueOf(text(row.get("phase")))));}
|
||||
var submissions=new ArrayList<FishingState.Submission>();for(Object value:list(root.get("submissions"))){var row=map(value);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(Object rewardValue:list(row.get("rewards"))){var reward=map(rewardValue);if(rewards.put(uuid(reward,"character"),new FishingState.Reward(number(reward.get("xp")),number(reward.get("coins"))))!=null)throw new IllegalArgumentException();}submissions.add(new FishingState.Submission(uuid(row,"id"),uuid(row,"quest"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"submitter"),uuid(row,"item"),number(row.get("quantity")),rewards,FishingState.SubmissionPhase.valueOf(text(row.get("phase"))),strings(row.get("audit"))));}
|
||||
var completions=new HashSet<FishingState.Completion>();for(Object value:list(root.get("completions"))){var row=map(value);completions.add(new FishingState.Completion(uuid(row,"catalog"),uuid(row,"site"),uuid(row,"character")));}
|
||||
var allocations=new ArrayList<FishingState.Allocation>();for(Object value:list(root.get("allocations"))){var row=map(value);allocations.add(new FishingState.Allocation(uuid(row,"id"),uuid(row,"submission"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"character"),number(row.get("xp")),number(row.get("coins"))));}
|
||||
var claims=new ArrayList<FishingState.Claim>();for(Object value:list(root.get("claims"))){var row=map(value);claims.add(new FishingState.Claim(uuid(row,"id"),uuid(row,"character"),uuids(row.get("allocations")),number(row.get("xp")),number(row.get("coins"))));}
|
||||
@@ -36,9 +36,9 @@ public final class YamlFishingRepository {
|
||||
root.put("inventory",state.inventory().stream().sorted(Comparator.comparing(value->value.character().toString()+value.item())).map(value->row("character",value.character(),"item",value.item(),"site",value.site(),"name",value.name(),"amount",value.amount())).toList());
|
||||
root.put("trophies",state.trophies().stream().sorted(Comparator.comparing(FishingState.Trophy::id)).map(value->row("id",value.id(),"type",value.type(),"carrier",value.carrier(),"catalog",value.catalog(),"site",value.site(),"quest",value.quest(),"source",value.source(),"name",value.name(),"origin",value.origin(),"phase",value.phase(),"rewards",value.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->row("character",entry.getKey(),"xp",entry.getValue().xp(),"coins",entry.getValue().coins())).toList())).toList());
|
||||
root.put("trophy-receipts",state.trophyReceipts().stream().sorted().map(UUID::toString).toList());
|
||||
root.put("catches",state.catches().stream().sorted(Comparator.comparing(FishingState.Catch::id)).map(value->row("id",value.id(),"character",value.character(),"quest",value.quest(),"site",value.site(),"item",value.item(),"item-name",value.itemName(),"xp",value.xp(),"item-awarded",value.itemAwarded())).toList());
|
||||
root.put("catches",state.catches().stream().sorted(Comparator.comparing(FishingState.Catch::id)).map(value->row("id",value.id(),"character",value.character(),"quest",value.quest(),"site",value.site(),"item",value.item(),"item-name",value.itemName(),"xp",value.xp(),"item-awarded",value.itemAwarded(),"audit",value.audit())).toList());
|
||||
root.put("catch-receipts",state.catchReceipts().stream().sorted().map(UUID::toString).toList());
|
||||
root.put("submissions",state.submissions().stream().sorted(Comparator.comparing(FishingState.Submission::id)).map(value->row("id",value.id(),"quest",value.quest(),"catalog",value.catalog(),"site",value.site(),"submitter",value.submitter(),"item",value.item(),"quantity",value.quantity(),"phase",value.phase(),"rewards",value.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->row("character",entry.getKey(),"xp",entry.getValue().xp(),"coins",entry.getValue().coins())).toList())).toList());
|
||||
root.put("submissions",state.submissions().stream().sorted(Comparator.comparing(FishingState.Submission::id)).map(value->row("id",value.id(),"quest",value.quest(),"catalog",value.catalog(),"site",value.site(),"submitter",value.submitter(),"item",value.item(),"quantity",value.quantity(),"phase",value.phase(),"audit",value.audit(),"rewards",value.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->row("character",entry.getKey(),"xp",entry.getValue().xp(),"coins",entry.getValue().coins())).toList())).toList());
|
||||
root.put("completions",state.completions().stream().sorted(Comparator.comparing(value->value.catalog().toString()+value.site()+value.character())).map(value->row("catalog",value.catalog(),"site",value.site(),"character",value.character())).toList());
|
||||
root.put("allocations",state.allocations().stream().sorted(Comparator.comparing(FishingState.Allocation::id)).map(value->row("id",value.id(),"submission",value.submission(),"catalog",value.catalog(),"site",value.site(),"character",value.character(),"xp",value.xp(),"coins",value.coins())).toList());
|
||||
root.put("claims",state.claims().stream().sorted(Comparator.comparing(FishingState.Claim::id)).map(value->row("id",value.id(),"character",value.character(),"allocations",value.allocations().stream().sorted().toList(),"xp",value.xp(),"coins",value.coins())).toList());
|
||||
@@ -47,6 +47,12 @@ public final class YamlFishingRepository {
|
||||
private static Map<String,Object> row(Object...pairs){var result=new LinkedHashMap<String,Object>();for(int index=0;index<pairs.length;index+=2)result.put((String)pairs[index],encode(pairs[index+1]));return result;}
|
||||
private static Object encode(Object value){if(value instanceof UUID||value instanceof Enum<?>||value instanceof BigInteger)return value.toString();if(value instanceof Collection<?> values)return values.stream().map(YamlFishingRepository::encode).toList();return value;}
|
||||
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(64);options.setCodePointLimit(4_194_304);return new Yaml(new SafeConstructor(options));}
|
||||
private static Map<String,String> strings(Object value){
|
||||
if(value==null)return Map.of();
|
||||
var result=new LinkedHashMap<String,String>();
|
||||
map(value).forEach((key,item)->{if(result.put(text(key),text(item))!=null)throw new IllegalArgumentException();});
|
||||
return result;
|
||||
}
|
||||
private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}
|
||||
private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}
|
||||
private static String text(Object value){if(value instanceof String result)return result;throw new IllegalArgumentException();}
|
||||
|
||||
@@ -83,6 +83,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
|
||||
private games.dmg.fantasystick.persistence.HardMobXpSettings hardMobXpSettings;
|
||||
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
|
||||
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
|
||||
private games.dmg.fantasystick.dungeon.DungeonCommands dungeonCommands;
|
||||
private games.dmg.fantasystick.party.CombatExperience combatExperience;
|
||||
@@ -371,6 +372,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed || adventureQuestCommands != null) throw new IllegalStateException("Adventure quests already attached or command closed");
|
||||
adventureQuestCommands = Objects.requireNonNull(commands); adventureQuestSettings = Objects.requireNonNull(settings);
|
||||
}
|
||||
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
|
||||
public void attachDungeons(games.dmg.fantasystick.dungeon.DungeonCommands commands){if(closed||dungeonCommands!=null)throw new IllegalStateException("Dungeons already attached or command closed");dungeonCommands=Objects.requireNonNull(commands);}
|
||||
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
|
||||
public void attachSheetResourcePackEvents(games.dmg.fantasystick.events.SheetResourcePackEventBridge events){
|
||||
@@ -1930,10 +1932,31 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
private void adventureQuestConfiguration(CommandSender sender, String[] args) {
|
||||
String key=args[1]; if(args.length==2){sender.sendMessage(key+"="+adventureQuestSettings.value(key)+" (applies to the owning later catalog, acceptance or encounter attempt)");return;}
|
||||
String key=args[1];
|
||||
boolean fishingKey=key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.FISHING_CATCH_XP_MIN)
|
||||
||key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.FISHING_CATCH_XP_MAX);
|
||||
var audit=fishingKey?fishingEvents:null;
|
||||
var actor=audit==null?null:audit.actor(sender);
|
||||
if(args.length==2){
|
||||
var effective=adventureQuestSettings.value(key);
|
||||
if(audit!=null)audit.configRead(actor,key,effective,games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
|
||||
sender.sendMessage(key+"="+effective+" (applies to the owning later catalog, acceptance or encounter attempt)");return;
|
||||
}
|
||||
var operationId=UUID.randomUUID();
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdventureQuestSettings.Update> operation;
|
||||
try{operation=adventureQuestSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Adventure quest setting is invalid; the effective value is unchanged.");return;}
|
||||
operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&adventureQuestSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Adventure quest configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+adventureQuestSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for later catalogs, acceptances or attempts; active state is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
|
||||
operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{
|
||||
if(closed)return;
|
||||
var before=adventureQuestSettings.value(key);
|
||||
boolean applied=failure==null&&adventureQuestSettings.publish(saved);
|
||||
var effective=adventureQuestSettings.value(key);
|
||||
if(applied&&audit!=null&&!before.equals(effective))audit.configChanged(actor,operationId,key,before,effective,
|
||||
games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
|
||||
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
|
||||
if(failure!=null)sender.sendMessage("Adventure quest configuration save failed; effective values are unchanged.");
|
||||
else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+effective+".");
|
||||
else sender.sendMessage("Saved "+key+"="+saved.value()+" for later catalogs, acceptances or attempts; active state is unchanged.");
|
||||
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
|
||||
}
|
||||
|
||||
private void partyConfiguration(CommandSender sender,String[] args){
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.events.EventInput;
|
||||
import games.dmg.fantasystick.events.FishingEventBridge;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class FishingEventBridgePartsNativeTest {
|
||||
@org.junit.jupiter.api.BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void unavailablePlayerWorldDoesNotEraseAnAuthorizedConfigRead() throws Exception {
|
||||
var published=new ArrayList<EventInput>();
|
||||
var sessions=mock(EquipmentSessions.class);
|
||||
var player=mock(org.bukkit.entity.Player.class);
|
||||
var id=UUID.randomUUID();when(player.getUniqueId()).thenReturn(id);
|
||||
when(player.getName()).thenReturn("Captain");
|
||||
when(sessions.active(id)).thenReturn(java.util.Optional.empty());
|
||||
var world=mock(org.bukkit.World.class);when(world.getName()).thenReturn(null);
|
||||
var location=new org.bukkit.Location(world,1,70,2);when(player.getLocation()).thenReturn(location);
|
||||
var bridge=new FishingEventBridge(event->{published.add(event);
|
||||
return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);
|
||||
},Clock.systemUTC(),data,sessions,ignored->{});
|
||||
var actor=bridge.actor(player);assertNotNull(actor);
|
||||
bridge.configRead(actor,"fishing-catch-xp-min","2","1");
|
||||
bridge.close();bridge.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
assertEquals(1,published.size());
|
||||
assertEquals("unavailable",published.getFirst().data().get("location_status"));
|
||||
assertEquals(id,published.getFirst().context().playerId());
|
||||
}
|
||||
|
||||
@Test void largeFrozenBeneficiaryRosterUsesBoundedLinkedPartsWithoutDroppingZeroShares() throws Exception {
|
||||
var published=new ArrayList<EventInput>();var failures=new ArrayList<Throwable>();
|
||||
var bridge=new FishingEventBridge(event->{published.add(event);
|
||||
return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);
|
||||
},Clock.systemUTC(),data,mock(EquipmentSessions.class),failures::add);
|
||||
var recipients=new ArrayList<Map<String,String>>();var roster=new ArrayList<String>();
|
||||
for(int index=0;index<90;index++){
|
||||
var id=UUID.randomUUID().toString();roster.add(id);
|
||||
recipients.add(Map.of("character_id",id,"allocated_xp",index==0?"1":"0",
|
||||
"allocated_coins","0","allocation_status",index==0?"positive":"zero_share_completion",
|
||||
"receipt_id",UUID.randomUUID().toString()));
|
||||
}
|
||||
var values=new LinkedHashMap<String,Object>();values.put("beneficiaries",recipients);values.put("roster",roster);
|
||||
values.put("site_id",UUID.randomUUID().toString());
|
||||
var operation=UUID.randomUUID();
|
||||
bridge.durable("adventure.items_submitted",operation,new FishingEventBridge.Actor("console",null),values);
|
||||
bridge.close();bridge.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
assertTrue(failures.isEmpty(),failures.toString());
|
||||
assertEquals(5,published.size(),"parts must fit the durable per-intent bound");
|
||||
assertEquals(90,published.stream().mapToInt(event->((List<?>)event.data().get("beneficiaries")).size()).sum());
|
||||
assertEquals(90,published.stream().mapToInt(event->((List<?>)event.data().get("roster")).size()).sum());
|
||||
assertEquals(5,published.stream().map(event->event.id()).distinct().count());
|
||||
assertTrue(published.stream().allMatch(event->event.actionId().equals(operation)
|
||||
&&event.data().get("part_count").equals(5)));
|
||||
}
|
||||
}
|
||||
@@ -25,18 +25,43 @@ final class FishingQuestNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Test void registeredFishingDefinitionCatchInventorySubmissionResetAndClaimUseConnectedPath() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\nfishing-catch-xp-min: 2\nfishing-catch-xp-max: 2\n");
|
||||
UUID giverId=null,giverWorld=null;
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);when(actor.world.getName()).thenReturn("Fishing Realm");f.activate(actor);doReturn(null).when(actor.player).getFishHook();
|
||||
assertTrue(f.feedbackCommand.execute(actor.player,"fantasyfeedback",new String[]{"off"}));
|
||||
for(int attempt=0;attempt<20&&!actor.messages.contains("Fantasy feedback is now off.");attempt++)f.tick();
|
||||
assertTrue(actor.messages.contains("Fantasy feedback is now off."));
|
||||
assertEquals(List.of("fishing"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","f"}));
|
||||
assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing",""}).contains("--name"));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","\"Moonlit Pond\"","--item","\"Silver Scale\"","--required","1","--chance","100","--xp","10","--coins","4","--radius","16","--mode","individual-only"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Placed fishing site")&&message.contains("Moonlit Pond")));
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var site=adventures.snapshot().sites().getFirst();assertEquals(AdventureQuestState.SiteType.FISHING,site.type());assertEquals("Moonlit Pond",site.name());assertEquals("Silver Scale",site.fishing().itemName());assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,site.participationMode());
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString()});assertTrue(actor.messages.getLast().contains("Silver Scale"));assertEquals(List.of(site.id().toString()),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","fishing",""}));
|
||||
var giver=mock(Villager.class);
|
||||
var giverData=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());
|
||||
when(giver.getPersistentDataContainer()).thenReturn(giverData);
|
||||
when(giver.getUniqueId()).thenReturn(UUID.randomUUID());when(giver.getWorld()).thenReturn(actor.world);
|
||||
when(giver.getLocation()).thenReturn(actor.core.location.clone());
|
||||
var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);
|
||||
when(chunk.getX()).thenReturn(actor.core.location.getBlockX()>>4);
|
||||
when(chunk.getZ()).thenReturn(actor.core.location.getBlockZ()>>4);
|
||||
when(chunk.getEntities()).thenReturn(new Entity[0]);when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(chunk);
|
||||
when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});
|
||||
when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
|
||||
doAnswer(call->{((java.util.function.Consumer<Villager>)call.getArgument(2)).accept(giver);return giver;})
|
||||
.when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"quest-giver","place","Harbor Guide"});
|
||||
await(f,()->!adventures.snapshot().givers().isEmpty());
|
||||
var registeredGiver=adventures.snapshot().givers().getFirst();giverId=registeredGiver.id();giverWorld=registeredGiver.world();
|
||||
var npcController=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");
|
||||
npcController.reconcile(chunk);
|
||||
int before=adventures.snapshot().sites().size();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","Broken","--item","X","--required","0","--chance","101","--xp","1","--coins","1"});assertEquals(before,adventures.snapshot().sites().size());
|
||||
var oldCatalog=adventures.snapshot().catalog();f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});await(f,()->adventures.snapshot().reset()==null&&adventures.snapshot().catalog()!=null&&(oldCatalog==null||!adventures.snapshot().catalog().revision().equals(oldCatalog.revision())));var catalog=adventures.snapshot().catalog();assertTrue(catalog.sites().contains(site.id()));
|
||||
var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.open(actor.player);drain(f);var offerMenu=actor.inventories.getLast();var offer=offer(offerMenu,"Moonlit Pond");var lore=plain(offer);assertTrue(lore.stream().anyMatch(line->line.contains("Mode: individual-only")));assertTrue(lore.stream().anyMatch(line->line.contains("Silver Scale × 1")));assertTrue(lore.stream().anyMatch(line->line.contains("Catch chance: 100%")));assertTrue(lore.stream().anyMatch(line->line.contains("10 XP, 4 coins")));click(controller,actor,offerMenu,offer);drain(f);var quest=adventures.active().getFirst();assertNotNull(quest.fishing());UUID stableItem=quest.fishing().itemId();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString(),"--item","\"Golden Scale\"","--required","2","--chance","0","--xp","20","--coins","8","--radius","4"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated fishing site")&&message.contains(site.id().toString())));var updatedSite=adventures.findSite(site.id()).orElseThrow();assertEquals(stableItem,updatedSite.fishing().itemId());assertEquals("Golden Scale",updatedSite.fishing().itemName());assertEquals("Silver Scale",quest.fishing().itemName(),"accepted terms remain immutable");assertEquals(16,quest.fishing().radius());actor.core.location.setX(10);
|
||||
var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");
|
||||
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drain(f);var offerMenu=actor.inventories.getLast();var offer=offer(offerMenu,"Moonlit Pond");var lore=plain(offer);assertTrue(lore.stream().anyMatch(line->line.contains("Mode: individual-only")));assertTrue(lore.stream().anyMatch(line->line.contains("Silver Scale × 1")));assertTrue(lore.stream().anyMatch(line->line.contains("Catch chance: 100%")));assertTrue(lore.stream().anyMatch(line->line.contains("10 XP, 4 coins")));click(controller,actor,offerMenu,offer);drain(f);var quest=adventures.active().getFirst();assertNotNull(quest.fishing());UUID stableItem=quest.fishing().itemId();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString(),"--item","\"Golden Scale\"","--required","2","--chance","0","--xp","20","--coins","8","--radius","4"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated fishing site")&&message.contains(site.id().toString())));var updatedSite=adventures.findSite(site.id()).orElseThrow();assertEquals(stableItem,updatedSite.fishing().itemId());assertEquals("Golden Scale",updatedSite.fishing().itemName());assertEquals("Silver Scale",quest.fishing().itemName(),"accepted terms remain immutable");assertEquals(16,quest.fishing().radius());f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","mode",site.id().toString(),"party-required"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated adventure site "+site.id()+" mode=party-required")));actor.core.location.setX(10);
|
||||
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}var rod=actor.core.inventory.getItem(0);assertEquals(Material.FISHING_ROD,rod.getType());assertEquals(quest.id(),AdventureQuestNavigation.fishingQuest(rod));assertTrue(rod.getEnchantments().isEmpty());
|
||||
var use=new PlayerInteractEvent(actor.player,Action.RIGHT_CLICK_AIR,rod,null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND);f.dispatcher.accept(use);assertNotEquals(org.bukkit.event.Event.Result.DENY,use.useItemInHand(),"owned quest rod keeps native cast/reel mechanics");
|
||||
var ordinaryHook=mock(FishHook.class);when(ordinaryHook.getUniqueId()).thenReturn(UUID.randomUUID());var ordinaryEvent=new PlayerFishEvent(actor.player,null,ordinaryHook,EquipmentSlot.HAND,PlayerFishEvent.State.REEL_IN);actor.core.inventory.setItem(0,new ItemStack(Material.FISHING_ROD));f.dispatcher.accept(ordinaryEvent);assertFalse(ordinaryEvent.isCancelled(),"ordinary fishing remains unchanged");
|
||||
@@ -47,7 +72,7 @@ final class FishingQuestNativeTest {
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.LURED));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
|
||||
var caughtEvent=new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);caughtEvent.setExpToDrop(7);f.dispatcher.accept(caughtEvent);assertTrue(caughtEvent.isCancelled());assertEquals(0,caughtEvent.getExpToDrop());verify(caught).remove();verify(hook).remove();await(f,()->fishing.quantity(actor.core.record.id(),site.fishing().itemId()).equals(BigInteger.ONE));var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(BigInteger.TWO));assertTrue(actor.messages.stream().anyMatch(message->message.contains("Caught Silver Scale")));
|
||||
var caughtEvent=new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);caughtEvent.setExpToDrop(7);f.dispatcher.accept(caughtEvent);assertTrue(caughtEvent.isCancelled());assertEquals(0,caughtEvent.getExpToDrop());verify(caught).remove();verify(hook).remove();await(f,()->fishing.quantity(actor.core.record.id(),site.fishing().itemId()).equals(BigInteger.ONE));var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(BigInteger.TWO));await(f,()->actor.messages.stream().anyMatch(message->message.contains("Caught Silver Scale")));
|
||||
var missedHook=mock(FishHook.class);when(missedHook.getUniqueId()).thenReturn(UUID.randomUUID());when(missedHook.getLocation()).thenReturn(actor.core.location.clone());
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,missedHook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,missedHook,EquipmentSlot.HAND,PlayerFishEvent.State.FAILED_ATTEMPT));
|
||||
@@ -72,9 +97,100 @@ final class FishingQuestNativeTest {
|
||||
assertTrue(emptyCatch.isCancelled());assertTrue(actor.messages.getLast().contains("no native fish item"));
|
||||
assertEquals(BigInteger.ONE,fishing.quantity(actor.core.record.id(),site.fishing().itemId()),"refused catches award no item");
|
||||
doReturn(true).when(actor.player).isSneaking();int books=actor.books.size();var sneak=new PlayerInteractEvent(actor.player,Action.RIGHT_CLICK_AIR,actor.core.inventory.getItem(0),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND);f.dispatcher.accept(sneak);await(f,()->actor.books.size()>books);var pages=((org.bukkit.inventory.meta.BookMeta)actor.books.getLast().getItemMeta()).pages().stream().map(PlainTextComponentSerializer.plainText()::serialize).toList();assertTrue(String.join("\n",pages).contains("Inventory"),pages.toString());assertTrue(String.join("\n",pages).contains("Golden Scale: 1"),pages.toString());assertTrue(String.join("",pages).replace("\n","").contains(site.id().toString()),pages.toString());doReturn(false).when(actor.player).isSneaking();
|
||||
controller.open(actor.player);drain(f);var offers=actor.inventories.getLast();assertTrue(plain(offer(offers,"Moonlit Pond")).stream().anyMatch(line->line.contains("Held: 1 / 1")),plain(offer(offers,"Moonlit Pond")).toString());var turnIn=java.util.stream.IntStream.range(0,offers.getSize()).mapToObj(offers::getItem).filter(Objects::nonNull).filter(item->item.getType()==Material.ENDER_CHEST).findFirst().orElseThrow();click(controller,actor,offers,turnIn);var turnMenu=actor.inventories.getLast();var submit=offer(turnMenu,"Submit Silver Scale");assertTrue(plain(submit).stream().anyMatch(line->line.contains("Beneficiaries: 1")));var fishingController=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");clickFishing(fishingController,actor,turnMenu,submit);clickFishing(fishingController,actor,turnMenu,submit);assertTrue(actor.messages.getLast().contains("menu, character or session changed"));await(f,()->adventures.active().isEmpty()&&fishing.quantity(actor.core.record.id(),site.fishing().itemId()).signum()==0);assertEquals(new FishingState.Reward(BigInteger.TEN,BigInteger.valueOf(4)),fishing.unclaimed(actor.core.record.id()));
|
||||
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drain(f);var offers=actor.inventories.getLast();assertTrue(plain(offer(offers,"Moonlit Pond")).stream().anyMatch(line->line.contains("Held: 1 / 1")),plain(offer(offers,"Moonlit Pond")).toString());var turnIn=java.util.stream.IntStream.range(0,offers.getSize()).mapToObj(offers::getItem).filter(Objects::nonNull).filter(item->item.getType()==Material.ENDER_CHEST).findFirst().orElseThrow();click(controller,actor,offers,turnIn);var turnMenu=actor.inventories.getLast();var submit=offer(turnMenu,"Submit Silver Scale");assertTrue(plain(submit).stream().anyMatch(line->line.contains("Beneficiaries: 1")));var fishingController=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");clickFishing(fishingController,actor,turnMenu,submit);clickFishing(fishingController,actor,turnMenu,submit);assertTrue(actor.messages.getLast().contains("menu, character or session changed"));await(f,()->adventures.active().isEmpty()&&fishing.quantity(actor.core.record.id(),site.fishing().itemId()).signum()==0);assertEquals(new FishingState.Reward(BigInteger.TEN,BigInteger.valueOf(4)),fishing.unclaimed(actor.core.record.id()));
|
||||
var completedGeneration=catalog.revision();f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});await(f,()->adventures.snapshot().reset()==null&&!adventures.snapshot().catalog().revision().equals(completedGeneration));assertEquals(new FishingState.Reward(BigInteger.TEN,BigInteger.valueOf(4)),fishing.unclaimed(actor.core.record.id()),"full reroll preserves earned shares");
|
||||
controller.open(actor.player);drain(f);var emptyOffers=actor.inventories.getLast();turnIn=java.util.stream.IntStream.range(0,emptyOffers.getSize()).mapToObj(emptyOffers::getItem).filter(Objects::nonNull).filter(item->item.getType()==Material.ENDER_CHEST).findFirst().orElseThrow();click(controller,actor,emptyOffers,turnIn);turnMenu=actor.inventories.getLast();var claim=offer(turnMenu,"Claim earned quest rewards");clickFishing((FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController"),actor,turnMenu,claim);await(f,()->fishing.unclaimed(actor.core.record.id()).equals(FishingState.Reward.empty()));var saved=storage.find(actor.core.record.id()).join().orElseThrow();assertEquals(BigInteger.valueOf(12),saved.experience());assertEquals(actor.core.record.coins().add(BigInteger.valueOf(4)),saved.coins());
|
||||
assertTrue(adventures.removeSite(site.id()).get(5,TimeUnit.SECONDS).isPresent(),"source registration may be removed before the frozen personal claim");
|
||||
int beforeStaleMenu=actor.inventories.size();click(controller,actor,offers,turnIn);
|
||||
assertEquals(beforeStaleMenu,actor.inventories.size(),"expired Quest Giver menu cannot reopen a new claim authority");
|
||||
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drain(f);var emptyOffers=actor.inventories.getLast();turnIn=java.util.stream.IntStream.range(0,emptyOffers.getSize()).mapToObj(emptyOffers::getItem).filter(Objects::nonNull).filter(item->item.getType()==Material.ENDER_CHEST).findFirst().orElseThrow();click(controller,actor,emptyOffers,turnIn);turnMenu=actor.inventories.getLast();var claim=offer(turnMenu,"Claim earned quest rewards");clickFishing((FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController"),actor,turnMenu,claim);await(f,()->fishing.unclaimed(actor.core.record.id()).equals(FishingState.Reward.empty()));var saved=storage.find(actor.core.record.id()).join().orElseThrow();assertEquals(BigInteger.valueOf(12),saved.experience());assertEquals(actor.core.record.coins().add(BigInteger.valueOf(4)),saved.coins());
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"config","fishing-catch-xp-min"});
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"config","fishing-catch-xp-max"});
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"config","fishing-catch-xp-max","3"});
|
||||
await(f,()->actor.messages.stream().anyMatch(message->message.contains("Saved fishing-catch-xp-max=3")));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"config","fishing-catch-xp-min","4"});
|
||||
assertTrue(actor.messages.getLast().contains("invalid"));
|
||||
await(f,()->{try{return exportedClaims().size()==1&&exportedTypes().stream()
|
||||
.anyMatch(type->type.endsWith("config.changed"));}
|
||||
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
List<String> types;
|
||||
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
types=paths.filter(path->path.toString().endsWith(".jsonl"))
|
||||
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
|
||||
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject().get("type").getAsString()).toList();
|
||||
}
|
||||
for(var required:List.of("adventure.site.registered","adventure.site.updated","adventure.site.mode_changed","adventure.quest.accepted",
|
||||
"fishing.catch_resolved","adventure.items_submitted","adventure.reward_claimed","config.read","config.changed"))
|
||||
assertTrue(types.stream().anyMatch(type->type.endsWith(required)),required+" missing from exported JSONL: "+types);
|
||||
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
var exported=paths.filter(path->path.toString().endsWith(".jsonl")).flatMap(path->{
|
||||
try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}
|
||||
}).map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject()).toList();
|
||||
var fishingActions=exported.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved"))
|
||||
.filter(event->event.getAsJsonObject("data").has("action_kind")
|
||||
&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("fishing")).toList();
|
||||
assertTrue(fishingActions.stream().anyMatch(event->event.getAsJsonObject("data")
|
||||
.get("state").getAsString().equals("CAUGHT_FISH")),"native reel decision missing from JSONL");
|
||||
assertTrue(fishingActions.stream().anyMatch(event->event.getAsJsonObject("data")
|
||||
.get("outcome").getAsString().equals("prior_cancelled")),"cancelled native cast must be observable");
|
||||
var catchEvents=exported.stream().filter(event->event.get("type").getAsString().endsWith("fishing.catch_resolved")).toList();
|
||||
assertEquals(1,catchEvents.size(),"only the acknowledged native item catch resolves");
|
||||
var catchData=catchEvents.getFirst().getAsJsonObject("data");
|
||||
assertTrue(catchData.get("item_awarded").getAsBoolean());
|
||||
assertEquals("suppressed",catchData.get("native_item_disposition").getAsString());
|
||||
assertEquals("suppressed",catchData.get("native_xp_disposition").getAsString());
|
||||
assertEquals("2",catchData.get("sampled_xp").getAsString());
|
||||
assertEquals("2",catchData.get("allocated_xp").getAsString());
|
||||
assertTrue(fishingActions.stream().anyMatch(event->event.getAsJsonObject("data")
|
||||
.has("candidate_catch_id")&&event.getAsJsonObject("data").get("candidate_catch_id").getAsString()
|
||||
.equals(catchData.get("catch_id").getAsString())),"native reel links to the acknowledged operation");
|
||||
assertEquals("2",catchData.get("applied_xp").getAsString());
|
||||
assertEquals("0",catchData.get("stock_before").getAsString());
|
||||
assertEquals("1",catchData.get("stock_after").getAsString());
|
||||
assertEquals("100",catchData.get("chance_percent").getAsString());
|
||||
assertEquals("both_inside_same_world_circle",catchData.get("geometry_decision").getAsString());
|
||||
assertEquals(catchData.getAsJsonObject("fisher_location").get("world_id").getAsString(),
|
||||
catchData.getAsJsonObject("bobber_location").get("world_id").getAsString());
|
||||
assertEquals(10d,catchData.getAsJsonObject("fisher_location").get("x").getAsDouble());
|
||||
assertEquals(16,catchData.get("radius").getAsInt());
|
||||
assertTrue(catchData.get("sample_percent").getAsDouble()<100d);
|
||||
var submitted=exported.stream().filter(event->event.get("type").getAsString().endsWith("adventure.items_submitted")).toList();
|
||||
assertEquals(1,submitted.size(),"forged/repeated menu cannot submit twice");
|
||||
var submission=submitted.getFirst().getAsJsonObject("data");
|
||||
assertEquals("1",submission.get("consumed").getAsString());
|
||||
assertEquals("1",submission.get("stock_before").getAsString());
|
||||
assertEquals("0",submission.get("stock_after").getAsString());
|
||||
assertEquals(giverId.toString(),submission.get("giver_id").getAsString());
|
||||
assertEquals(giverWorld.toString(),submission.getAsJsonObject("giver_location")
|
||||
.get("world_id").getAsString());
|
||||
assertEquals("10",submission.getAsJsonArray("beneficiaries").get(0).getAsJsonObject().get("allocated_xp").getAsString());
|
||||
assertEquals("4",submission.getAsJsonArray("beneficiaries").get(0).getAsJsonObject().get("allocated_coins").getAsString());
|
||||
var claimed=exported.stream().filter(event->event.get("type").getAsString().endsWith("adventure.reward_claimed")).toList();
|
||||
assertEquals(1,claimed.size());
|
||||
var claimData=claimed.getFirst().getAsJsonObject("data");
|
||||
assertEquals("10",claimData.get("applied_xp").getAsString());
|
||||
assertEquals("4",claimData.get("applied_coins").getAsString());
|
||||
assertEquals(giverId.toString(),claimData.get("giver_id").getAsString());
|
||||
assertEquals(giverWorld.toString(),claimData.getAsJsonObject("giver_location")
|
||||
.get("world_id").getAsString());
|
||||
assertEquals(submission.get("submission_id").getAsString(),claimData.getAsJsonArray("sources").get(0)
|
||||
.getAsJsonObject().get("source_receipt_id").getAsString());
|
||||
assertEquals(submission.get("catalog_id").getAsString(),claimData.getAsJsonArray("sources").get(0)
|
||||
.getAsJsonObject().get("catalog_id").getAsString());
|
||||
assertEquals(submission.get("site_id").getAsString(),claimData.getAsJsonArray("sources").get(0)
|
||||
.getAsJsonObject().get("site_id").getAsString());
|
||||
var configReads=exported.stream().filter(event->event.get("type").getAsString().endsWith("config.read"))
|
||||
.filter(event->event.getAsJsonObject("data").get("key").getAsString().startsWith("fishing-catch-xp-")).toList();
|
||||
assertEquals(List.of("fishing-catch-xp-min","fishing-catch-xp-max"),configReads.stream()
|
||||
.map(event->event.getAsJsonObject("data").get("key").getAsString()).toList());
|
||||
var configChanges=exported.stream().filter(event->event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event->event.getAsJsonObject("data").get("key").getAsString().startsWith("fishing-catch-xp-")).toList();
|
||||
assertEquals(1,configChanges.size(),"invalid/unchanged changes cannot claim publication");
|
||||
assertEquals("2",configChanges.getFirst().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("3",configChanges.getFirst().getAsJsonObject("data").get("effective").getAsString());
|
||||
assertTrue(exported.stream().noneMatch(event->event.get("type").getAsString().endsWith("combat.reward_resolved")),
|
||||
"fishing's shared award helper must not impersonate combat reward ownership");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +223,24 @@ final class FishingQuestNativeTest {
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,caught,next,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH));
|
||||
verify(next).remove();verify(caught,never()).remove();await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(BigInteger.valueOf(4)));
|
||||
assertEquals(BigInteger.ZERO,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));
|
||||
await(f,()->fishing.snapshot().catchReceipts().size()==2);
|
||||
await(f,()->{try{return exportedCatches().size()==2;}
|
||||
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
var resolved=paths.filter(path->path.toString().endsWith(".jsonl"))
|
||||
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
|
||||
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
|
||||
.filter(event->event.get("type").getAsString().endsWith("fishing.catch_resolved")).toList();
|
||||
assertEquals(2,resolved.size(),"cancelled, stale or forged callbacks are not acknowledged catches");
|
||||
assertTrue(resolved.stream().allMatch(event->{var result=event.getAsJsonObject("data");
|
||||
return !result.get("item_awarded").getAsBoolean()
|
||||
&&result.get("native_item_disposition").getAsString().equals("retained")
|
||||
&&result.get("native_xp_disposition").getAsString().equals("suppressed")
|
||||
&&result.get("chance_percent").getAsString().equals("0")
|
||||
&&result.get("stock_after").getAsString().equals("0");
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +271,20 @@ final class FishingQuestNativeTest {
|
||||
var expectedStock=attempt==0?BigInteger.ZERO:BigInteger.ONE;await(f,()->fishing.quantity(actor.core.record.id(),site.fishing().itemId()).equals(expectedStock));
|
||||
}
|
||||
verify(random,times(2)).nextDouble(100d);assertEquals(BigInteger.ONE,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));
|
||||
await(f,()->{try{return exportedCatches().size()==2;}
|
||||
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var catches=exportedCatches();assertEquals(2,catches.size());
|
||||
var outcomes=catches.stream().map(event->event.getAsJsonObject("data")).toList();
|
||||
assertEquals(Set.of("75.0","25.0"),outcomes.stream()
|
||||
.map(value->value.get("sample_percent").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertTrue(outcomes.stream().anyMatch(value->!value.get("item_awarded").getAsBoolean()
|
||||
&&value.get("native_item_disposition").getAsString().equals("retained")
|
||||
&&value.get("native_item_type").getAsString().equals("NAME_TAG")));
|
||||
assertTrue(outcomes.stream().anyMatch(value->value.get("item_awarded").getAsBoolean()
|
||||
&&value.get("native_item_disposition").getAsString().equals("suppressed")
|
||||
&&value.get("native_item_type").getAsString().equals("COD")));
|
||||
}
|
||||
|
||||
@Test void nonleaderMemberSuppliesWholeRequirementAndOfflinePartySharePersists() throws Exception {
|
||||
@@ -146,6 +293,387 @@ final class FishingQuestNativeTest {
|
||||
f.plugin.setEnabled(true);var leader=new FantasyStickLifecycleNativeTest.Actor(data);var member=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(leader.core.id,leader.player);f.players.put(member.core.id,member.player);UUID world=UUID.randomUUID();when(leader.world.getUID()).thenReturn(world);member.core.location.setWorld(leader.world);f.activate(leader);f.activate(member);doReturn(null).when(leader.player).getFishHook();doReturn(null).when(member.player).getFishHook();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()));
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var site=adventures.placeFishingSite("Party Pond","Party Pearl",world,0,64,0,16,java.math.BigDecimal.valueOf(100),BigInteger.ONE,BigInteger.valueOf(100),BigInteger.valueOf(20),AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5,TimeUnit.SECONDS);var old=adventures.snapshot().catalog();var result=adventures.rerollToday(old==null?null:old.revision()).get(5,TimeUnit.SECONDS);var reset=adventures.snapshot().reset();if(reset!=null)adventures.completeReset(reset.operation()).get(5,TimeUnit.SECONDS);var catalog=result.catalog().orElseThrow();var accepted=adventures.accept(catalog.revision(),site.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(leader.core.record.id(),member.core.record.id())).get(5,TimeUnit.SECONDS).quest().orElseThrow();var catchPlan=new FishingState.Catch(UUID.randomUUID(),member.core.record.id(),accepted.id(),site.id(),site.fishing().itemId(),site.fishing().itemName(),BigInteger.ZERO,true);fishing.prepareCatch(catchPlan).get();fishing.completeCatch(catchPlan.id()).get();doReturn(false).when(leader.player).isOnline();
|
||||
var controller=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");controller.open(member.player);var menu=member.inventories.getLast();var submit=offer(menu,"Submit Party Pearl");clickFishing(controller,member,menu,submit);await(f,()->adventures.findQuest(accepted.id()).isEmpty());var leaderReward=fishing.unclaimed(leader.core.record.id());var memberReward=fishing.unclaimed(member.core.record.id());assertEquals(BigInteger.valueOf(100),leaderReward.xp().add(memberReward.xp()));assertEquals(BigInteger.valueOf(20),leaderReward.coins().add(memberReward.coins()));assertTrue(leaderReward.xp().signum()>0&&memberReward.xp().signum()>0,"offline accepted leader retains a frozen share");assertEquals(BigInteger.ZERO,fishing.quantity(member.core.record.id(),site.fishing().itemId()));
|
||||
await(f,()->{try{return exportedTypes().stream().anyMatch(type->type.endsWith("adventure.items_submitted"));}
|
||||
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
var submissions=paths.filter(path->path.toString().endsWith(".jsonl"))
|
||||
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
|
||||
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
|
||||
.filter(event->event.get("type").getAsString().endsWith("adventure.items_submitted")).toList();
|
||||
assertEquals(1,submissions.size());
|
||||
var submission=submissions.getFirst().getAsJsonObject("data");
|
||||
assertEquals("party",submission.get("unit_kind").getAsString());
|
||||
assertEquals(2,submission.getAsJsonArray("roster").size());
|
||||
assertEquals(2,submission.getAsJsonArray("beneficiaries").size());
|
||||
int totalXp=0,totalCoins=0;boolean offline=false;
|
||||
for(var value:submission.getAsJsonArray("beneficiaries")){
|
||||
var share=value.getAsJsonObject();
|
||||
totalXp+=share.get("allocated_xp").getAsInt();totalCoins+=share.get("allocated_coins").getAsInt();
|
||||
offline|=!share.get("online_at_submission").getAsBoolean();
|
||||
}
|
||||
assertEquals(100,totalXp);assertEquals(20,totalCoins);assertTrue(offline);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void transferredCharacterClaimsItsFrozenFishingShareFromTheNewHolderSession() throws Exception {
|
||||
var identities=new UUID[2];
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id,a.player);f.activate(a);
|
||||
var character=a.core.record.id();identities[0]=character;
|
||||
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
|
||||
var quest=UUID.randomUUID();var site=UUID.randomUUID();var catalog=UUID.randomUUID();var item=UUID.randomUUID();
|
||||
var catchReceipt=new FishingState.Catch(UUID.randomUUID(),character,quest,site,item,"Transferred Scale",BigInteger.ZERO,true);
|
||||
fishing.prepareCatch(catchReceipt).get(5,TimeUnit.SECONDS);
|
||||
fishing.completeCatch(catchReceipt.id()).get(5,TimeUnit.SECONDS);
|
||||
var submission=new FishingState.Submission(UUID.randomUUID(),quest,catalog,site,character,item,
|
||||
BigInteger.ONE,Map.of(character,new FishingState.Reward(BigInteger.valueOf(7),BigInteger.valueOf(5))),
|
||||
FishingState.SubmissionPhase.PREPARED);
|
||||
fishing.prepareSubmission(submission).get(5,TimeUnit.SECONDS);
|
||||
fishing.commitSubmission(submission.id()).get(5,TimeUnit.SECONDS);
|
||||
fishing.finishSubmission(submission.id()).get(5,TimeUnit.SECONDS);
|
||||
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,sessions.exit(a.player));
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
var b=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(b.core.id,b.player);
|
||||
identities[1]=b.core.id;
|
||||
b.core.inventory.setItemInOffHand(a.core.inventory.getItemInOffHand().clone());
|
||||
f.activate(b);var holder=sessions.active(b.core.id).orElseThrow();assertEquals(character,holder.character());
|
||||
var controller=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
|
||||
controller.open(b.player);var menu=b.inventories.getLast();
|
||||
clickFishing(controller,b,menu,offer(menu,"Claim earned quest rewards"));
|
||||
await(f,()->fishing.unclaimed(character).equals(FishingState.Reward.empty()));
|
||||
var saved=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
|
||||
await(f,()->saved.find(character).join().orElseThrow().experience().equals(BigInteger.valueOf(7)));
|
||||
assertEquals(a.core.record.coins().add(BigInteger.valueOf(5)),
|
||||
saved.find(character).get(5,TimeUnit.SECONDS).orElseThrow().coins());
|
||||
await(f,()->{try{return exportedClaims().size()==1;}
|
||||
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
{
|
||||
var claims=exportedClaims();
|
||||
assertEquals(1,claims.size());var value=claims.getFirst();
|
||||
assertEquals("7",value.getAsJsonObject("data").get("applied_xp").getAsString());
|
||||
assertEquals("5",value.getAsJsonObject("data").get("applied_coins").getAsString());
|
||||
assertEquals(identities[0].toString(),value.getAsJsonObject("data").get("character_id").getAsString());
|
||||
assertEquals(identities[1].toString(),value.getAsJsonObject("data").get("player_id").getAsString());
|
||||
assertNotEquals(identities[0],identities[1]);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void exactHorizontalRadiusCreditsOnlyTheValidNativeBobberAndExportsItsGeometry() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\nfishing-catch-xp-min: 2\nfishing-catch-xp-max: 2\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id,actor.player);var world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);
|
||||
f.activate(actor);doReturn(null).when(actor.player).getFishHook();
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
|
||||
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
|
||||
var site=adventures.placeFishingSite("Border Pond","Border Scale",world,0,70,0,16,
|
||||
java.math.BigDecimal.valueOf(100),BigInteger.ONE,BigInteger.TEN,BigInteger.ZERO,
|
||||
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,TimeUnit.SECONDS);
|
||||
var prior=adventures.snapshot().catalog();var catalog=adventures.rerollToday(prior==null?null:prior.revision()).get(5,TimeUnit.SECONDS);
|
||||
if(adventures.snapshot().reset()!=null)adventures.completeReset(adventures.snapshot().reset().operation()).get(5,TimeUnit.SECONDS);
|
||||
adventures.accept(catalog.catalog().orElseThrow().revision(),site.id(),AdventureQuestState.UnitType.SOLO,
|
||||
actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS);
|
||||
actor.core.location.setX(16);actor.core.location.setZ(0);
|
||||
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}
|
||||
assertEquals(Material.FISHING_ROD,actor.core.inventory.getItem(0).getType());
|
||||
var hook=mock(FishHook.class);when(hook.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(hook.getLocation()).thenReturn(actor.core.location.clone());var caught=mock(Item.class);
|
||||
when(caught.getItemStack()).thenReturn(new ItemStack(Material.COD));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
|
||||
var exact=new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);
|
||||
f.dispatcher.accept(exact);assertTrue(exact.isCancelled());
|
||||
await(f,()->fishing.quantity(actor.core.record.id(),site.fishing().itemId()).equals(BigInteger.ONE));
|
||||
var outside=mock(FishHook.class);when(outside.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var outPosition=actor.core.location.clone();outPosition.setX(16.001);
|
||||
when(outside.getLocation()).thenReturn(outPosition);
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,outside,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
|
||||
var refused=new PlayerFishEvent(actor.player,caught,outside,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);
|
||||
f.dispatcher.accept(refused);assertTrue(refused.isCancelled());
|
||||
assertEquals(BigInteger.ONE,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));
|
||||
await(f,()->{try{return exportedCatches().size()==1;}
|
||||
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var catches=exportedCatches();assertEquals(1,catches.size());
|
||||
var captured=catches.getFirst().getAsJsonObject("data");
|
||||
assertEquals("both_inside_same_world_circle",captured.get("geometry_decision").getAsString());
|
||||
assertEquals(16d,captured.getAsJsonObject("fisher_location").get("x").getAsDouble());
|
||||
assertEquals(16d,captured.getAsJsonObject("bobber_location").get("x").getAsDouble());
|
||||
assertEquals("16",captured.get("radius").getAsString());
|
||||
}
|
||||
|
||||
@Test void mixedFishingAndTrophySharesKeepTheirOwnSourcesThroughOnePersonalClaim() throws Exception {
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id,actor.player);f.activate(actor);
|
||||
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
|
||||
var character=actor.core.record.id();var fishSite=UUID.randomUUID();var fishCatalog=UUID.randomUUID();
|
||||
var fishQuest=UUID.randomUUID();var fishItem=UUID.randomUUID();var submissionId=UUID.randomUUID();
|
||||
var caught=new FishingState.Catch(UUID.randomUUID(),character,fishQuest,fishSite,fishItem,"Scale",BigInteger.ZERO,true);
|
||||
fishing.prepareCatch(caught).get(5,TimeUnit.SECONDS);fishing.completeCatch(caught.id()).get(5,TimeUnit.SECONDS);
|
||||
var submission=new FishingState.Submission(submissionId,fishQuest,fishCatalog,fishSite,character,fishItem,
|
||||
BigInteger.ONE,Map.of(character,new FishingState.Reward(BigInteger.ONE,BigInteger.ONE)),
|
||||
FishingState.SubmissionPhase.PREPARED);
|
||||
fishing.prepareSubmission(submission).get(5,TimeUnit.SECONDS);
|
||||
fishing.commitSubmission(submissionId).get(5,TimeUnit.SECONDS);
|
||||
fishing.finishSubmission(submissionId).get(5,TimeUnit.SECONDS);
|
||||
var trophyId=UUID.randomUUID();var trophySite=UUID.randomUUID();var trophyCatalog=UUID.randomUUID();
|
||||
var trophy=new FishingState.Trophy(trophyId,FishingState.TrophyType.CHAMPION,character,trophyCatalog,
|
||||
trophySite,UUID.randomUUID(),UUID.randomUUID(),"Champion Head","Champion Grounds",
|
||||
Map.of(character,new FishingState.Reward(BigInteger.TWO,BigInteger.valueOf(3))));
|
||||
fishing.prepareTrophy(trophy).get(5,TimeUnit.SECONDS);
|
||||
fishing.commitTrophy(trophyId).get(5,TimeUnit.SECONDS);
|
||||
fishing.redeemTrophy(trophyId,character).get(5,TimeUnit.SECONDS);
|
||||
var controller=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
|
||||
controller.open(actor.player);var menu=actor.inventories.getLast();
|
||||
clickFishing(controller,actor,menu,offer(menu,"Claim earned quest rewards"));
|
||||
await(f,()->fishing.unclaimed(character).equals(FishingState.Reward.empty()));
|
||||
var saved=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
|
||||
await(f,()->saved.find(character).join().orElseThrow().experience().equals(BigInteger.valueOf(3)));
|
||||
await(f,()->{try{return exportedClaims().size()==1;}
|
||||
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
var claims=paths.filter(path->path.toString().endsWith(".jsonl"))
|
||||
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
|
||||
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
|
||||
.filter(value->value.get("type").getAsString().endsWith("adventure.reward_claimed")).toList();
|
||||
assertEquals(1,claims.size());var claim=claims.getFirst().getAsJsonObject("data");
|
||||
assertEquals("3",claim.get("requested_xp").getAsString());
|
||||
assertEquals("4",claim.get("requested_coins").getAsString());
|
||||
assertEquals(2,claim.getAsJsonArray("sources").size());
|
||||
assertEquals(Set.of("fishing","trophy"),java.util.stream.StreamSupport
|
||||
.stream(claim.getAsJsonArray("sources").spliterator(),false)
|
||||
.map(value->value.getAsJsonObject().get("source_kind").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectedWriterReplaysAcknowledgedCatchWithoutRefishingOrAwardingXpAgain() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\nfishing-catch-xp-min: 2\nfishing-catch-xp-max: 2\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id,actor.player);var world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);
|
||||
f.activate(actor);doReturn(null).when(actor.player).getFishHook();
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
|
||||
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
|
||||
var site=adventures.placeFishingSite("Restart Pond","Restart Scale",world,0,64,0,16,
|
||||
java.math.BigDecimal.valueOf(100),BigInteger.ONE,BigInteger.TEN,BigInteger.ZERO,
|
||||
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,TimeUnit.SECONDS);
|
||||
var before=adventures.snapshot().catalog();var offered=adventures.rerollToday(before==null?null:before.revision()).get(5,TimeUnit.SECONDS);
|
||||
if(adventures.snapshot().reset()!=null)adventures.completeReset(adventures.snapshot().reset().operation()).get(5,TimeUnit.SECONDS);
|
||||
adventures.accept(offered.catalog().orElseThrow().revision(),site.id(),AdventureQuestState.UnitType.SOLO,
|
||||
actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS);
|
||||
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}
|
||||
((games.dmg.fantasystick.events.LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();
|
||||
var hook=mock(FishHook.class);when(hook.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(hook.getLocation()).thenReturn(actor.core.location.clone());var caught=mock(Item.class);
|
||||
when(caught.getItemStack()).thenReturn(new ItemStack(Material.COD));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH));
|
||||
await(f,()->fishing.quantity(actor.core.record.id(),site.fishing().itemId()).equals(BigInteger.ONE));
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
|
||||
assertEquals(BigInteger.TWO,storage.find(actor.core.record.id()).get(5,TimeUnit.SECONDS).orElseThrow().experience());
|
||||
f.plugin.setEnabled(false);
|
||||
assertEquals(0,exportedCatches().size(),"pending intents are not exported JSONL");
|
||||
f.plugin.setEnabled(true);
|
||||
for(int attempt=0;attempt<200&&exportedCatches().isEmpty();attempt++)Thread.sleep(10);
|
||||
assertEquals(BigInteger.ONE,((FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing"))
|
||||
.quantity(actor.core.record.id(),site.fishing().itemId()));
|
||||
assertEquals(BigInteger.TWO,((CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters"))
|
||||
.find(actor.core.record.id()).get(5,TimeUnit.SECONDS).orElseThrow().experience());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertEquals(1,exportedCatches().size(),"writer replay cannot duplicate a native catch");
|
||||
}
|
||||
|
||||
private List<String> exportedTypes() throws java.io.IOException {
|
||||
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
return paths.filter(path->path.toString().endsWith(".jsonl"))
|
||||
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
|
||||
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject().get("type").getAsString()).toList();
|
||||
}
|
||||
}
|
||||
|
||||
private List<com.google.gson.JsonObject> exportedClaims() throws java.io.IOException {
|
||||
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
return paths.filter(path->path.toString().endsWith(".jsonl"))
|
||||
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
|
||||
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
|
||||
.filter(value->value.get("type").getAsString().endsWith("adventure.reward_claimed")).toList();
|
||||
}
|
||||
}
|
||||
|
||||
private List<com.google.gson.JsonObject> exportedCatches() throws java.io.IOException {
|
||||
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
return paths.filter(path->path.toString().endsWith(".jsonl"))
|
||||
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
|
||||
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
|
||||
.filter(value->value.get("type").getAsString().endsWith("fishing.catch_resolved")).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedNativeCatchPreparationProducesNoItemXpOrResolvedSuccess() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\nfishing-catch-xp-min: 2\nfishing-catch-xp-max: 2\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id,actor.player);var world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);
|
||||
f.activate(actor);doReturn(null).when(actor.player).getFishHook();
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
|
||||
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
|
||||
var site=adventures.placeFishingSite("Unavailable Pond","Quest Scale",world,0,64,0,16,
|
||||
java.math.BigDecimal.valueOf(100),BigInteger.ONE,BigInteger.TEN,BigInteger.ZERO,
|
||||
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,TimeUnit.SECONDS);
|
||||
var before=adventures.snapshot().catalog();var offered=adventures.rerollToday(before==null?null:before.revision()).get(5,TimeUnit.SECONDS);
|
||||
if(adventures.snapshot().reset()!=null)adventures.completeReset(adventures.snapshot().reset().operation()).get(5,TimeUnit.SECONDS);
|
||||
adventures.accept(offered.catalog().orElseThrow().revision(),site.id(),AdventureQuestState.UnitType.SOLO,
|
||||
actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS);
|
||||
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}
|
||||
java.nio.file.Files.createDirectory(data.resolve("fishing.yml"));
|
||||
var hook=mock(FishHook.class);when(hook.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(hook.getLocation()).thenReturn(actor.core.location.clone());var caught=mock(Item.class);
|
||||
when(caught.getItemStack()).thenReturn(new ItemStack(Material.COD));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
|
||||
var event=new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);
|
||||
f.dispatcher.accept(event);assertTrue(event.isCancelled());verify(caught).remove();
|
||||
var tasks=f.tasks.poll(5,TimeUnit.SECONDS);if(tasks!=null)tasks.run();
|
||||
assertEquals(BigInteger.ZERO,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));
|
||||
var characters=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
|
||||
assertEquals(BigInteger.ZERO,characters.find(actor.core.record.id()).get(5,TimeUnit.SECONDS)
|
||||
.orElseThrow().experience());
|
||||
java.nio.file.Files.delete(data.resolve("fishing.yml"));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
var catches=paths.filter(path->path.toString().endsWith(".jsonl"))
|
||||
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
|
||||
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
|
||||
.filter(value->value.get("type").getAsString().endsWith("fishing.catch_resolved")).toList();
|
||||
assertTrue(catches.isEmpty(),"unacknowledged native catch cannot claim saved stock or Fantasy XP");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cappedNativeCatchRecordsSampledFantasyXpLossWithoutInventingAnAward() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\nfishing-catch-xp-min: 2\nfishing-catch-xp-max: 2\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old=repository.load(actor.core.record.id()).orElseThrow();
|
||||
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),
|
||||
old.revision().add(BigInteger.ONE),old.stats(),30,BigInteger.valueOf(2900),old.unspentPoints(),
|
||||
old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),
|
||||
old.dead(),old.deathId(),old.diedAt(),old.coins(),old.coinsInitialized(),old.rewardReceipts(),
|
||||
old.pvpCoinEscrow(),old.gear(),old.sheetGeneration()),old.revision());
|
||||
f.players.put(actor.core.id,actor.player);var world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);
|
||||
f.activate(actor);doReturn(null).when(actor.player).getFishHook();
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
|
||||
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
|
||||
var site=adventures.placeFishingSite("Capped Pond","Pearl",world,0,64,0,16,
|
||||
java.math.BigDecimal.valueOf(100),BigInteger.ONE,BigInteger.TEN,BigInteger.ZERO,
|
||||
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,TimeUnit.SECONDS);
|
||||
var before=adventures.snapshot().catalog();var offered=adventures.rerollToday(before==null?null:before.revision()).get(5,TimeUnit.SECONDS);
|
||||
if(adventures.snapshot().reset()!=null)adventures.completeReset(adventures.snapshot().reset().operation()).get(5,TimeUnit.SECONDS);
|
||||
adventures.accept(offered.catalog().orElseThrow().revision(),site.id(),AdventureQuestState.UnitType.SOLO,
|
||||
actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS);
|
||||
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}
|
||||
var hook=mock(FishHook.class);when(hook.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(hook.getLocation()).thenReturn(actor.core.location.clone());var caught=mock(Item.class);
|
||||
when(caught.getItemStack()).thenReturn(new ItemStack(Material.COD));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH));
|
||||
await(f,()->fishing.quantity(actor.core.record.id(),site.fishing().itemId()).equals(BigInteger.ONE));
|
||||
assertEquals(BigInteger.valueOf(2900),repository.load(old.id()).orElseThrow().experience());
|
||||
var controller=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
|
||||
controller.open(actor.player);var submissionMenu=actor.inventories.getLast();
|
||||
clickFishing(controller,actor,submissionMenu,offer(submissionMenu,"Submit Pearl"));
|
||||
await(f,()->fishing.unclaimed(actor.core.record.id()).xp().equals(BigInteger.TEN));
|
||||
controller.open(actor.player);var claimMenu=actor.inventories.getLast();
|
||||
clickFishing(controller,actor,claimMenu,offer(claimMenu,"Claim earned quest rewards"));
|
||||
await(f,()->fishing.unclaimed(actor.core.record.id()).equals(FishingState.Reward.empty()));
|
||||
assertEquals(BigInteger.valueOf(2900),repository.load(old.id()).orElseThrow().experience());
|
||||
await(f,()->{try{return exportedClaims().size()==1&&exportedCatches().size()==1;}
|
||||
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
var catches=paths.filter(path->path.toString().endsWith(".jsonl"))
|
||||
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
|
||||
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
|
||||
.filter(event->event.get("type").getAsString().endsWith("fishing.catch_resolved")).toList();
|
||||
assertEquals(1,catches.size());var value=catches.getFirst().getAsJsonObject("data");
|
||||
assertEquals("at_cap",value.get("award_status").getAsString());
|
||||
assertEquals("2",value.get("sampled_xp").getAsString());
|
||||
assertEquals("0",value.get("applied_xp").getAsString());
|
||||
assertEquals("2",value.get("lost_xp").getAsString());
|
||||
assertEquals("1",value.get("stock_after").getAsString());
|
||||
}
|
||||
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
var claims=paths.filter(path->path.toString().endsWith(".jsonl"))
|
||||
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
|
||||
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
|
||||
.filter(event->event.get("type").getAsString().endsWith("adventure.reward_claimed")).toList();
|
||||
assertEquals(1,claims.size());var value=claims.getFirst().getAsJsonObject("data");
|
||||
assertEquals("at_cap",value.get("award_status").getAsString());
|
||||
assertEquals("10",value.get("requested_xp").getAsString());
|
||||
assertEquals("0",value.get("applied_xp").getAsString());
|
||||
assertEquals("10",value.get("lost_xp").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void zeroShareBeneficiaryHasACompletionReceiptWithoutAnInventedAllocation() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var leader=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id,leader.player);f.players.put(member.core.id,member.player);
|
||||
var world=UUID.randomUUID();when(leader.world.getUID()).thenReturn(world);
|
||||
member.core.location.setWorld(leader.world);f.activate(leader);f.activate(member);
|
||||
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()));
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
|
||||
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
|
||||
var site=adventures.placeFishingSite("Sparse Pond","Sparse Scale",world,0,64,0,16,
|
||||
java.math.BigDecimal.valueOf(100),BigInteger.ONE,BigInteger.ONE,BigInteger.ZERO,
|
||||
AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5,TimeUnit.SECONDS);
|
||||
var old=adventures.snapshot().catalog();var roll=adventures.rerollToday(old==null?null:old.revision()).get(5,TimeUnit.SECONDS);
|
||||
if(adventures.snapshot().reset()!=null)adventures.completeReset(adventures.snapshot().reset().operation()).get(5,TimeUnit.SECONDS);
|
||||
var accepted=adventures.accept(roll.catalog().orElseThrow().revision(),site.id(),
|
||||
AdventureQuestState.UnitType.PARTY,party.id(),Set.of(leader.core.record.id(),member.core.record.id()))
|
||||
.get(5,TimeUnit.SECONDS).quest().orElseThrow();
|
||||
var catchPlan=new FishingState.Catch(UUID.randomUUID(),member.core.record.id(),accepted.id(),site.id(),
|
||||
site.fishing().itemId(),site.fishing().itemName(),BigInteger.ZERO,true);
|
||||
fishing.prepareCatch(catchPlan).get(5,TimeUnit.SECONDS);fishing.completeCatch(catchPlan.id()).get(5,TimeUnit.SECONDS);
|
||||
var controller=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
|
||||
controller.open(member.player);var menu=member.inventories.getLast();clickFishing(controller,member,menu,offer(menu,"Submit Sparse Scale"));
|
||||
await(f,()->adventures.findQuest(accepted.id()).isEmpty());
|
||||
assertTrue(fishing.completed(accepted.catalog(),site.id(),leader.core.record.id()));
|
||||
assertTrue(fishing.completed(accepted.catalog(),site.id(),member.core.record.id()));
|
||||
await(f,()->{try{return exportedTypes().stream().anyMatch(type->type.endsWith("adventure.items_submitted"));}
|
||||
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
var submissions=paths.filter(path->path.toString().endsWith(".jsonl"))
|
||||
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
|
||||
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
|
||||
.filter(event->event.get("type").getAsString().endsWith("adventure.items_submitted")).toList();
|
||||
assertEquals(1,submissions.size());
|
||||
var shares=submissions.getFirst().getAsJsonObject("data").getAsJsonArray("beneficiaries");
|
||||
assertEquals(2,shares.size());
|
||||
assertEquals(1,java.util.stream.StreamSupport.stream(shares.spliterator(),false)
|
||||
.filter(value->value.getAsJsonObject().get("allocated_xp").getAsInt()==0
|
||||
&&value.getAsJsonObject().get("allocation_status").getAsString().equals("zero_share_completion")).count());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,14 @@ final class FishingDirectoryTest {
|
||||
@Test void preparedCatchRecoversAndCompletesItemExactlyOnce() throws Exception {
|
||||
UUID operation=UUID.randomUUID(),character=UUID.randomUUID(),site=UUID.randomUUID(),item=UUID.randomUUID();
|
||||
var plan=new FishingState.Catch(operation,character,UUID.randomUUID(),site,item,"Silver Scale",
|
||||
BigInteger.valueOf(3),true);
|
||||
BigInteger.valueOf(3),true,Map.of("hook_id",UUID.randomUUID().toString(),"chance_percent","42.5",
|
||||
"sample_percent","23.75","native_item_disposition","suppressed"));
|
||||
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
assertEquals(FishingDirectory.PrepareStatus.APPLIED,directory.prepareCatch(plan).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(plan,directory.pendingCatches().getFirst());
|
||||
}
|
||||
try(var restarted=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
assertEquals(plan,restarted.pendingCatches().getFirst(),"native capture survives writer restart before claim completion");
|
||||
assertEquals(FishingDirectory.CompleteStatus.APPLIED,restarted.completeCatch(operation).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(BigInteger.ONE,restarted.quantity(character,item));
|
||||
assertEquals("Silver Scale",restarted.inventory(character).getFirst().name());
|
||||
@@ -37,6 +39,58 @@ final class FishingDirectoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void serialCatchReceiptCarriesItsOwnAuthoritativeStockDelta() throws Exception {
|
||||
var character=UUID.randomUUID();var site=UUID.randomUUID();var item=UUID.randomUUID();
|
||||
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
for(int expected=0;expected<2;expected++){
|
||||
var operation=UUID.randomUUID();
|
||||
var plan=new FishingState.Catch(operation,character,UUID.randomUUID(),site,item,"Scale",
|
||||
BigInteger.ONE,true);
|
||||
assertEquals(FishingDirectory.PrepareStatus.APPLIED,directory.prepareCatch(plan).get(5,TimeUnit.SECONDS));
|
||||
var receipt=directory.completeCatchWithEvidence(operation).get(5,TimeUnit.SECONDS);
|
||||
assertEquals(FishingDirectory.CompleteStatus.APPLIED,receipt.status());
|
||||
assertEquals(BigInteger.valueOf(expected),receipt.stockBefore());
|
||||
assertEquals(BigInteger.valueOf(expected+1),receipt.stockAfter());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void committedSubmissionReturnsTheSavedStockDeltaNotALaterInventoryGuess() throws Exception {
|
||||
var character=UUID.randomUUID();var site=UUID.randomUUID();var item=UUID.randomUUID();var quest=UUID.randomUUID();
|
||||
var catalog=UUID.randomUUID();var receipt=UUID.randomUUID();var submission=UUID.randomUUID();
|
||||
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
var caught=new FishingState.Catch(receipt,character,quest,site,item,"Scale",BigInteger.ZERO,true);
|
||||
directory.prepareCatch(caught).get(5,TimeUnit.SECONDS);
|
||||
directory.completeCatch(receipt).get(5,TimeUnit.SECONDS);
|
||||
var plan=new FishingState.Submission(submission,quest,catalog,site,character,item,BigInteger.ONE,
|
||||
Map.of(character,FishingState.Reward.empty()),FishingState.SubmissionPhase.PREPARED);
|
||||
directory.prepareSubmission(plan).get(5,TimeUnit.SECONDS);
|
||||
var committed=directory.commitSubmissionWithEvidence(submission).get(5,TimeUnit.SECONDS);
|
||||
assertEquals(FishingDirectory.CommitStatus.APPLIED,committed.status());
|
||||
assertEquals(BigInteger.ONE,committed.stockBefore());
|
||||
assertEquals(BigInteger.ZERO,committed.stockAfter());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void preparedSubmissionRetainsCapturedUnitAndZeroShareRosterAfterRestart() throws Exception {
|
||||
var character=UUID.randomUUID();var other=UUID.randomUUID();var site=UUID.randomUUID();
|
||||
var item=UUID.randomUUID();var quest=UUID.randomUUID();var party=UUID.randomUUID();
|
||||
var catchId=UUID.randomUUID();
|
||||
var plan=new FishingState.Submission(UUID.randomUUID(),quest,UUID.randomUUID(),site,character,item,
|
||||
BigInteger.ONE,Map.of(character,new FishingState.Reward(BigInteger.ONE,BigInteger.ZERO),
|
||||
other,FishingState.Reward.empty()),FishingState.SubmissionPhase.PREPARED,
|
||||
Map.of("unit_kind","party","unit_id",party.toString(),"roster",character+","+other));
|
||||
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
directory.prepareCatch(new FishingState.Catch(catchId,character,quest,site,item,"Scale",
|
||||
BigInteger.ZERO,true)).get(5,TimeUnit.SECONDS);
|
||||
directory.completeCatch(catchId).get(5,TimeUnit.SECONDS);
|
||||
assertEquals(FishingDirectory.PrepareStatus.APPLIED,directory.prepareSubmission(plan).get(5,TimeUnit.SECONDS));
|
||||
}
|
||||
try(var restarted=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
assertEquals(plan,restarted.pendingSubmissions().getFirst());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void submissionConsumesOneCharactersStockAndFreezesConservedNonexpiringShares() throws Exception {
|
||||
UUID character=UUID.randomUUID(),other=UUID.randomUUID(),site=UUID.randomUUID(),item=UUID.randomUUID();
|
||||
UUID catchOperation=UUID.randomUUID(),catalog=UUID.randomUUID(),quest=UUID.randomUUID(),submission=UUID.randomUUID();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class FeatureEventOutboxCollectionsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void durableFishingRosterAndAllocationsReplayAsStructuredJsonValues() throws Exception {
|
||||
var original = new EventInput(UUID.randomUUID(), UUID.randomUUID(), Instant.parse("2026-09-26T00:00:00Z"),
|
||||
"adventure.items_submitted", null, Map.of("roster", List.of("first", "second"),
|
||||
"allocations", List.of(Map.of("character_id", "first", "xp", "2", "coins", "0"),
|
||||
Map.of("character_id", "second", "xp", "0", "coins", "0"))));
|
||||
var failures = new ArrayList<Throwable>();
|
||||
var writer = new FeatureEventOutbox(data, "fishing-event-intents",
|
||||
Set.of("adventure.items_submitted"), event -> CompletableFuture.completedFuture(LocalEventLog.Delivery.REJECTED),
|
||||
failures::add);
|
||||
writer.accepted(original);
|
||||
writer.close();
|
||||
writer.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertTrue(failures.isEmpty(), failures.toString());
|
||||
var exported = new ArrayList<EventInput>();
|
||||
var reader = new FeatureEventOutbox(data, "fishing-event-intents",
|
||||
Set.of("adventure.items_submitted"), event -> {
|
||||
exported.add(event);
|
||||
return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);
|
||||
}, failures::add);
|
||||
reader.replay();
|
||||
reader.close();
|
||||
reader.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertTrue(failures.isEmpty(), failures.toString());
|
||||
assertEquals(List.of(original), exported);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user