feat(adventure): audit champion trophies and passive spacing
Release / release (push) Failing after 19m36s
Release / release (push) Failing after 19m36s
This commit is contained in:
1 parent
ec46e667f1
commit
5923fcaf75
13 files changed
+828
-43
No files matched your search
@@ -677,7 +677,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
adventureQuestController.registerDungeonRoomPresence((dungeon,location)->location.getWorld()!=null&&dungeons.find(dungeon).filter(value->value.world().equals(location.getWorld().getUID())).isPresent()&&dungeons.roomAt(dungeon,location.getBlockX(),location.getBlockY(),location.getBlockZ()).isPresent());
|
||||
combatExperience.attachManagedOrigins(entity->adventureQuestController.encounterOrigin(entity).or(()->dungeonController.encounterOrigin(entity)));
|
||||
combatExperience.attachManagedSettlement(dungeonController::settleKill);
|
||||
combatExperience.attachExcluded(adventureQuestController::managed);
|
||||
combatExperience.attachExcluded(entity->adventureQuestController.encounterOrigin(entity).isPresent());
|
||||
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|
||||
|| armorerController.tagged(entity) || rareMerchantController.tagged(entity) || adventureQuestController.giver(entity));
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
|
||||
@@ -38,18 +38,41 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private FishingController fishing;
|
||||
private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private final Map<UUID,Attempt> attempts=new HashMap<>();private final Map<UUID,Instant> readyAt=new HashMap<>();private final Set<UUID> starting=new HashSet<>();
|
||||
/** A bounded same-enable receipt for genuine deaths after their attempt has been cleaned up. */
|
||||
private final LinkedHashMap<UUID,games.dmg.fantasystick.progression.ManagedEncounterOrigin> dyingOrigins=new LinkedHashMap<>();
|
||||
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;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;}}
|
||||
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;
|
||||
UUID defeatedEntity;Map<String,Object> defeatLocation=Map.of("status","unavailable");Instant defeatedAt;
|
||||
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");}
|
||||
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}pulseTask=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20,20);publishPendingReset();}
|
||||
public boolean giver(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&GIVER.equals(data.get(typeKey,PersistentDataType.STRING));}
|
||||
public boolean enemy(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&ENEMY.equals(data.get(typeKey,PersistentDataType.STRING));}
|
||||
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();return enemy(entity)||data!=null&&PROJECTILE.equals(data.get(typeKey,PersistentDataType.STRING));}
|
||||
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){if(!enemy(entity))return Optional.empty();var data=entity.getPersistentDataContainer();UUID site=site(entity),catalog=parse(data.get(catalogKey,PersistentDataType.STRING)),source=parse(data.get(attemptKey,PersistentDataType.STRING));String encoded=data.get(encounterKindKey,PersistentDataType.STRING);if(site==null||catalog==null||source==null||encoded==null)return Optional.empty();games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind kind;try{kind=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.valueOf(encoded);}catch(IllegalArgumentException invalid){return Optional.empty();}if(kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION&&kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP)return Optional.empty();var active=attempts.get(site);if(active!=null&&(!active.catalog.equals(catalog)||!active.id.equals(source)))return Optional.empty();return Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,source));}
|
||||
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){
|
||||
if(!enemy(entity)||entity.getUniqueId()==null)return Optional.empty();
|
||||
var data=entity.getPersistentDataContainer();UUID site=site(entity);
|
||||
UUID catalog=parse(data.get(catalogKey,PersistentDataType.STRING));
|
||||
UUID source=parse(data.get(attemptKey,PersistentDataType.STRING));
|
||||
String encoded=data.get(encounterKindKey,PersistentDataType.STRING);
|
||||
if(site==null||catalog==null||source==null||encoded==null)return Optional.empty();
|
||||
games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind kind;
|
||||
try{kind=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.valueOf(encoded);}
|
||||
catch(IllegalArgumentException invalid){return Optional.empty();}
|
||||
if(kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION
|
||||
&&kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP)return Optional.empty();
|
||||
var origin=new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,source);
|
||||
var remembered=dyingOrigins.get(entity.getUniqueId());
|
||||
if(origin.equals(remembered))return Optional.of(origin);
|
||||
var active=attempts.get(site);
|
||||
return active!=null&&active.catalog.equals(catalog)&&active.id.equals(source)
|
||||
&&active.entities.contains(entity.getUniqueId())&&active.type.name().equals(kind.name())
|
||||
?Optional.of(origin):Optional.empty();
|
||||
}
|
||||
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,AdventureQuestState.Giver>();for(var giver:directory.snapshot().givers())if(giver.world().equals(chunk.getWorld().getUID())&§ion(giver.x())==chunk.getX()&§ion(giver.z())==chunk.getZ())desired.put(giver.id(),giver);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(managed(entity)){entity.remove();continue;}if(!giver(entity))continue;UUID id=id(entity,idKey);var value=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||value==null||!retained.add(id)){entity.remove();continue;}configure(villager,value);var expected=location(value);var current=villager.getLocation();if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawnGiver(value);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawnGiver(value);}
|
||||
public void removeGiverLoaded(UUID id){for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)&&id.equals(id(entity,idKey)))entity.remove();}
|
||||
public void removeSiteLoaded(UUID id){var attempt=attempts.remove(id);if(attempt!=null)cleanup(attempt);readyAt.remove(id);refreshTrackingPlayers();}
|
||||
@@ -89,13 +112,66 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private void startChampion(AdventureQuestState.Site site,UUID catalog){if(closed||attempts.containsKey(site.id())||starting.contains(site.id()))return;var world=plugin.getServer().getWorld(site.world());if(world==null||!world.isChunkLoaded(section(site.x()),section(site.z())))return;starting.add(site.id());try{var rules=settings.rules();var attempt=new Attempt(site.id(),catalog,site.type(),arena(site),rules.resetSeconds());attempt.bossBar=plugin.getServer().createBossBar("Daybreak Champion",BarColor.RED,BarStyle.SOLID);attempts.put(site.id(),attempt);world.spawn(new Location(world,site.x(),site.y(),site.z()),Vindicator.class,champion->{configureEnemy(champion,attempt);var maximum=Objects.requireNonNull(champion.getAttribute(Attribute.MAX_HEALTH));maximum.setBaseValue(rules.championHealth());champion.setHealth(rules.championHealth());Objects.requireNonNull(champion.getAttribute(Attribute.ATTACK_DAMAGE)).setBaseValue(rules.championDamage());champion.customName(net.kyori.adventure.text.Component.text("Daybreak Champion"));champion.setCustomNameVisible(true);attempt.champion=champion;});syncBossBar(attempt);}catch(RuntimeException failure){var attempt=attempts.remove(site.id());if(attempt!=null)cleanup(attempt);plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion attempt could not start at site "+site.id(),failure);}finally{starting.remove(site.id());}}
|
||||
private void spawnEnemy(World world,AdventureQuestState.Site site,org.bukkit.entity.EntityType type,Attempt attempt,int index){var location=new Location(world,site.x()+(index%3)-1,site.y(),site.z()+(index/3));switch(type){case HUSK->world.spawn(location,Husk.class,mob->configureEnemy(mob,attempt));case PILLAGER->world.spawn(location,Pillager.class,mob->configureEnemy(mob,attempt));case VINDICATOR->world.spawn(location,Vindicator.class,mob->configureEnemy(mob,attempt));case WITCH->world.spawn(location,Witch.class,mob->configureEnemy(mob,attempt));default->throw new IllegalArgumentException("Unsupported camp entity");}}
|
||||
private void configureEnemy(Mob mob,Attempt attempt){mob.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,ENEMY);mob.getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,attempt.site.toString());mob.getPersistentDataContainer().set(catalogKey,PersistentDataType.STRING,attempt.catalog.toString());mob.getPersistentDataContainer().set(attemptKey,PersistentDataType.STRING,attempt.id.toString());var kind=attempt.type==AdventureQuestState.SiteType.CHAMPION?games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP;mob.getPersistentDataContainer().set(encounterKindKey,PersistentDataType.STRING,kind.name());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}attempt.entities.add(mob.getUniqueId());}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(enemy(event.getEntity())){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),site(event.getEntity()))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID site=site(by.getDamager());if(!(event.getEntity() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(enemy(event.getEntity())&&encounterOrigin(event.getEntity()).isPresent()){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),site(event.getEntity()))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID site=site(by.getDamager());if(!(event.getEntity() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void accepted(EntityDamageByEntityEvent event){if(!enemy(event.getEntity())||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;var player=source(event);if(player==null)return;var quest=eligible(player,site(event.getEntity()));if(quest!=null){var attempt=attempts.get(quest.site());if(attempt!=null){attempt.represented.add(quest.id());if(attempt.champion==event.getEntity()&&attempt.bossBar!=null){double maximum=Objects.requireNonNull(attempt.champion.getAttribute(Attribute.MAX_HEALTH)).getValue();attempt.bossBar.setProgress(Math.max(0,Math.min(1,(attempt.champion.getHealth()-event.getFinalDamage())/maximum)));}}}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void death(EntityDeathEvent event){if(!enemy(event.getEntity()))return;event.getDrops().clear();event.setDroppedExp(0);UUID site=site(event.getEntity());var attempt=site==null?null:attempts.get(site);if(attempt==null)return;attempt.entities.remove(event.getEntity().getUniqueId());if(attempt.entities.isEmpty())clear(attempt);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void death(EntityDeathEvent event){
|
||||
if(!enemy(event.getEntity()))return;
|
||||
UUID site=site(event.getEntity());var attempt=site==null?null:attempts.get(site);
|
||||
if(attempt==null||!attempt.entities.remove(event.getEntity().getUniqueId()))return;
|
||||
event.getDrops().clear();event.setDroppedExp(0);
|
||||
if(attempt.type==AdventureQuestState.SiteType.CHAMPION||attempt.type==AdventureQuestState.SiteType.CAMP){
|
||||
var kind=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.valueOf(attempt.type.name());
|
||||
dyingOrigins.put(event.getEntity().getUniqueId(),
|
||||
new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,attempt.id));
|
||||
if(dyingOrigins.size()>512)dyingOrigins.remove(dyingOrigins.keySet().iterator().next());
|
||||
}
|
||||
if(fishingEvents!=null&&(attempt.type==AdventureQuestState.SiteType.CHAMPION
|
||||
||attempt.type==AdventureQuestState.SiteType.CAMP)){
|
||||
var target=event.getEntity();var data=new LinkedHashMap<String,Object>();
|
||||
var kind=attempt.type.name().toLowerCase(Locale.ROOT);
|
||||
data.put("resolution","encounter_suppressed");data.put("managed_origin_kind",kind);
|
||||
data.put("managed_origin_source",attempt.id.toString());data.put("target_id",target.getUniqueId().toString());
|
||||
data.put("target_type",target.getType()==null?"unavailable":target.getType().name().toLowerCase(Locale.ROOT));
|
||||
data.put("site_id",attempt.site.toString());data.put("catalog_id",attempt.catalog.toString());
|
||||
data.put("defeat_location",trophyLocation(target.getLocation()));
|
||||
data.put("spacing_status","managed_origin_exempt");
|
||||
data.put("ordinary_history","not_consulted_or_updated");
|
||||
data.put("experience_pool","0");data.put("coin_pool","0");
|
||||
data.put("allocated_experience","0");data.put("allocated_coins","0");
|
||||
data.put("applied_experience","0");data.put("applied_coins","0");
|
||||
data.put("suppressed_experience","0");data.put("suppressed_coins","0");
|
||||
data.put("suppression_reason","champion_and_camp_per_enemy_payout_disabled");
|
||||
var operation=UUID.nameUUIDFromBytes(("managed-suppressed:"+attempt.id+":"+target.getUniqueId())
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
fishingEvents.durable("combat.reward_resolved",operation,
|
||||
new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null),data);
|
||||
}
|
||||
if(attempt.entities.isEmpty()){attempt.defeatedEntity=event.getEntity().getUniqueId();
|
||||
attempt.defeatLocation=trophyLocation(event.getEntity().getLocation());attempt.defeatedAt=clock.instant();
|
||||
clear(attempt);}}
|
||||
private static Map<String,Object> trophyLocation(Location location){
|
||||
if(location==null||location.getWorld()==null)return Map.of("status","unavailable");
|
||||
try{var world=location.getWorld();if(world.getUID()==null)return Map.of("status","unavailable");
|
||||
var value=new LinkedHashMap<String,Object>();value.put("world_id",world.getUID().toString());
|
||||
value.put("world_name",world.getName()==null?"unavailable":world.getName());
|
||||
value.put("x",location.getX());value.put("y",location.getY());value.put("z",location.getZ());
|
||||
return Map.copyOf(value);
|
||||
}catch(RuntimeException unavailable){return Map.of("status","unavailable");}
|
||||
}
|
||||
private void clear(Attempt attempt){attempts.remove(attempt.site);if(attempt.bossBar!=null)attempt.bossBar.removeAll();readyAt.put(attempt.site,clock.instant().plusSeconds(attempt.resetSeconds));for(UUID questId:Set.copyOf(attempt.represented)){var quest=directory.findQuest(questId).orElse(null);if(quest==null)continue;var recipients=recipients(quest,attempt.site,attempt.arena);if(!recipients.isEmpty())award(quest,recipients,attempt);} }
|
||||
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site){var value=directory.findSite(site).orElse(null);return value==null?List.of():recipients(quest,site,arena(value));}
|
||||
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return List.of();var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(directory.characterClaimed(quest.catalog(),quest.site(),character))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),value,arena))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
|
||||
private void award(AdventureQuestState.Quest quest,List<UUID> recipients,Attempt attempt){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);if((attempt.type==AdventureQuestState.SiteType.CHAMPION||attempt.type==AdventureQuestState.SiteType.CAMP)&&fishing!=null){var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);var site=directory.findSite(quest.site()).orElse(null);if(carrier==null||site==null){plugin.getLogger().warning("Champion trophy carrier unavailable for quest "+quest.id());return;}var trophyType=attempt.type==AdventureQuestState.SiteType.CHAMPION?FishingState.TrophyType.CHAMPION:FishingState.TrophyType.CAMP;UUID trophy=UUID.nameUUIDFromBytes((trophyType.name().toLowerCase(java.util.Locale.ROOT)+":"+quest.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8));String trophyName=trophyType==FishingState.TrophyType.CHAMPION?"Daybreak Champion Head":"Spoils of "+site.displayName();var plan=new FishingState.Trophy(trophy,trophyType,carrier,quest.catalog(),quest.site(),quest.id(),attempt.id,trophyName,site.displayName(),rewards);fishing.earnTrophy(plan).whenComplete((earned,failure)->schedule(()->{if(failure!=null||!earned){plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion trophy persistence failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: its trophy is stored with the captured carrier.");}refreshTrackingPlayers();}));return;}var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));directory.bankAndComplete(quest.id(),quest.id(),rewards).whenComplete((banked,failure)->schedule(()->{if(failure!=null||!banked){plugin.getLogger().log(java.util.logging.Level.WARNING,"Adventure completion reward banking failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: rewards are ready at any Quest Giver.");}refreshTrackingPlayers();}));}
|
||||
private void award(AdventureQuestState.Quest quest,List<UUID> recipients,Attempt attempt){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);if((attempt.type==AdventureQuestState.SiteType.CHAMPION||attempt.type==AdventureQuestState.SiteType.CAMP)&&fishing!=null){var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);var site=directory.findSite(quest.site()).orElse(null);if(carrier==null||site==null){plugin.getLogger().warning("Champion trophy carrier unavailable for quest "+quest.id());return;}var trophyType=attempt.type==AdventureQuestState.SiteType.CHAMPION?FishingState.TrophyType.CHAMPION:FishingState.TrophyType.CAMP;UUID trophy=UUID.nameUUIDFromBytes((trophyType.name().toLowerCase(java.util.Locale.ROOT)+":"+quest.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8));String trophyName=trophyType==FishingState.TrophyType.CHAMPION?"Daybreak Champion Head":"Spoils of "+site.displayName();var audit=new LinkedHashMap<String,String>();
|
||||
audit.put("champion_id",attempt.defeatedEntity==null?"unavailable":attempt.defeatedEntity.toString());
|
||||
audit.put("defeated_at",attempt.defeatedAt==null?"unavailable":attempt.defeatedAt.truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString());
|
||||
for(var entry:attempt.defeatLocation.entrySet())
|
||||
audit.put("defeat_"+entry.getKey(),String.valueOf(entry.getValue()));
|
||||
audit.put("unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));
|
||||
audit.put("unit_id",quest.unit().toString());
|
||||
audit.put("accepted_roster",String.join(",",quest.snapshot().stream().sorted().map(UUID::toString).toList()));
|
||||
var plan=new FishingState.Trophy(trophy,trophyType,carrier,quest.catalog(),quest.site(),quest.id(),
|
||||
attempt.id,trophyName,site.displayName(),rewards,FishingState.TrophyPhase.PREPARED,audit);
|
||||
fishing.earnTrophyWithEvidence(plan).whenComplete((earned,failure)->schedule(()->{if(failure!=null||earned==null||!earned.acknowledged()){plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion trophy persistence failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: its trophy is stored with the captured carrier.");}refreshTrackingPlayers();}));return;}var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));directory.bankAndComplete(quest.id(),quest.id(),rewards).whenComplete((banked,failure)->schedule(()->{if(failure!=null||!banked){plugin.getLogger().log(java.util.logging.Level.WARNING,"Adventure completion reward banking failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: rewards are ready at any Quest Giver.");}refreshTrackingPlayers();}));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void splash(org.bukkit.event.entity.PotionSplashEvent event){if(!managed(event.getPotion()))return;UUID site=site(event.getPotion());for(var affected:List.copyOf(event.getAffectedEntities()))if(!(affected instanceof Player player)||site==null||eligible(player,site)==null)event.setIntensity(affected,0);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void target(EntityTargetLivingEntityEvent event){if(enemy(event.getEntity())&&event.getTarget()!=null){UUID site=site(event.getEntity());if(!(event.getTarget() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void projectile(org.bukkit.event.entity.ProjectileLaunchEvent event){if(event.getEntity().getShooter() instanceof Entity shooter&&enemy(shooter)){event.getEntity().getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,PROJECTILE);event.getEntity().getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,Objects.requireNonNull(site(shooter)).toString());}}
|
||||
@@ -126,5 +202,5 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private static UUID parse(String value){try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
|
||||
private static UUID id(Entity entity,NamespacedKey key){return parse(entity.getPersistentDataContainer().get(key,PersistentDataType.STRING));}
|
||||
private static <T>List<T> safe(Collection<T> values){return values==null?List.of():List.copyOf(values);}
|
||||
@Override public void close(){for(var player:plugin.getServer().getOnlinePlayers()){sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(sessions::restoreBook);}closed=true;if(pulseTask!=null)pulseTask.cancel();for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
|
||||
@Override public void close(){for(var player:plugin.getServer().getOnlinePlayers()){sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(sessions::restoreBook);}closed=true;if(pulseTask!=null)pulseTask.cancel();for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();dyingOrigins.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
|
||||
}
|
||||
@@ -37,8 +37,36 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
@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 record TrophyEarned(boolean acknowledged,boolean newlyEarned,FishingState.Trophy receipt) {}
|
||||
public java.util.concurrent.CompletionStage<Boolean> earnTrophy(FishingState.Trophy trophy){
|
||||
return earnTrophyWithEvidence(trophy).thenApply(TrophyEarned::acknowledged);
|
||||
}
|
||||
public java.util.concurrent.CompletionStage<TrophyEarned> earnTrophyWithEvidence(FishingState.Trophy trophy){
|
||||
return fishing.prepareTrophy(trophy).thenCompose(status->{
|
||||
if(status==FishingDirectory.TrophyPrepareStatus.REDEEMED)
|
||||
return java.util.concurrent.CompletableFuture.completedFuture(new TrophyEarned(true,false,null));
|
||||
var retained=fishing.trophy(trophy.id()).orElseThrow();
|
||||
return adventures.complete(retained.quest(),retained.rewards().keySet())
|
||||
.thenCompose(ignored->fishing.commitTrophy(retained.id()))
|
||||
.thenApply(committed->{
|
||||
var earned=committed==FishingDirectory.TrophyCommitStatus.NOT_FOUND?retained:retained.earned();
|
||||
if(committed==FishingDirectory.TrophyCommitStatus.APPLIED&&fishingEvents!=null)
|
||||
fishingEvents.championEarned(earned);
|
||||
return new TrophyEarned(committed!=FishingDirectory.TrophyCommitStatus.NOT_FOUND,
|
||||
committed==FishingDirectory.TrophyCommitStatus.APPLIED,earned);
|
||||
});
|
||||
});
|
||||
}
|
||||
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((committed,failure)->{
|
||||
if(failure!=null)report(trophy.id(),trophy.carrier(),"trophy-reconcile",failure);
|
||||
else if(committed==FishingDirectory.TrophyCommitStatus.APPLIED&&fishingEvents!=null)
|
||||
fishingEvents.championEarned(trophy.earned());
|
||||
});
|
||||
}
|
||||
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){
|
||||
@@ -225,7 +253,50 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
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,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 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;}
|
||||
var captured=fishing.trophy(trophy).orElse(null);
|
||||
var giver=menuGivers.get(player.getUniqueId());
|
||||
var actor=fishingEvents==null?null:fishingEvents.actor(player);
|
||||
var interactionLocation=locationMap(capture(player.getLocation()));
|
||||
fishing.redeemTrophyWithEvidence(trophy,context.record().id()).whenComplete((receipt,failure)->schedule(()->{
|
||||
if(failure!=null){report(trophy,context.record().id(),"trophy-redeem",failure);
|
||||
player.sendMessage("Trophy redemption failed; the earned trophy is retained.");return;}
|
||||
var status=receipt==null?null:receipt.status();
|
||||
if(status==FishingDirectory.TrophyRedeemStatus.APPLIED){
|
||||
if(captured!=null&&captured.type()==FishingState.TrophyType.CHAMPION&&fishingEvents!=null){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("receipt_id",captured.id().toString());
|
||||
data.put("trophy_type","champion_trophy");data.put("trophy_name",captured.name());
|
||||
data.put("source_name",captured.origin());data.put("carrier_character_id",captured.carrier().toString());
|
||||
data.put("catalog_id",captured.catalog().toString());data.put("site_id",captured.site().toString());
|
||||
data.put("quest_id",captured.quest().toString());data.put("encounter_id",captured.source().toString());
|
||||
data.put("redeemer_session_id",context.active().session().toString());
|
||||
data.put("redeemer_location",interactionLocation);
|
||||
data.put("giver_status",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 allocations=receipt.allocations();
|
||||
var beneficiaries=new ArrayList<Map<String,String>>();
|
||||
captured.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->{
|
||||
var share=new LinkedHashMap<String,String>();share.put("character_id",entry.getKey().toString());
|
||||
share.put("allocated_xp",entry.getValue().xp().toString());
|
||||
share.put("allocated_coins",entry.getValue().coins().toString());
|
||||
var saved=allocations.stream().filter(value->value.submission().equals(captured.id())
|
||||
&&value.character().equals(entry.getKey())).findFirst().orElse(null);
|
||||
share.put("allocation_status",saved==null?"zero_share_completion":"unlocked");
|
||||
if(saved!=null)share.put("allocation_id",saved.id().toString());
|
||||
beneficiaries.add(share);
|
||||
});
|
||||
data.put("beneficiaries",beneficiaries);
|
||||
fishingEvents.durable("adventure.trophy_redeemed",captured.id(),actor==null
|
||||
?new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null):actor,data);
|
||||
}
|
||||
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){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);});}
|
||||
@@ -264,7 +335,7 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
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("source_kind",source.sourceKind());
|
||||
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);
|
||||
|
||||
@@ -29,7 +29,42 @@ public final class FishingDirectory implements AutoCloseable {
|
||||
public List<FishingState.Trophy> trophies(UUID carrier){return snapshot.trophies().stream().filter(value->value.phase()==FishingState.TrophyPhase.EARNED&&value.carrier().equals(carrier)).sorted(Comparator.comparing(FishingState.Trophy::name).thenComparing(FishingState.Trophy::id)).toList();}
|
||||
public CompletableFuture<TrophyPrepareStatus> prepareTrophy(FishingState.Trophy requested){Objects.requireNonNull(requested);return mutate(state->{if(state.trophyReceipts().contains(requested.id()))return result(state,TrophyPrepareStatus.REDEEMED);var existing=state.trophies().stream().filter(value->value.id().equals(requested.id())).findFirst();if(existing.isPresent()){if(!sameTrophyCompletion(existing.orElseThrow(),requested))throw new IllegalStateException("Conflicting adventure trophy");return result(state,TrophyPrepareStatus.EXISTING);}var trophies=new ArrayList<>(state.trophies());trophies.add(requested);return result(copyTrophies(state,trophies,state.trophyReceipts(),state.allocations()),TrophyPrepareStatus.APPLIED);});}
|
||||
public CompletableFuture<TrophyCommitStatus> commitTrophy(UUID id){return mutate(state->{var trophy=state.trophies().stream().filter(value->value.id().equals(id)).findFirst().orElse(null);if(trophy==null)return result(state,TrophyCommitStatus.NOT_FOUND);if(trophy.phase()==FishingState.TrophyPhase.EARNED)return result(state,TrophyCommitStatus.DUPLICATE);var trophies=state.trophies().stream().map(value->value.id().equals(id)?value.earned():value).toList();return result(copyTrophies(state,trophies,state.trophyReceipts(),state.allocations()),TrophyCommitStatus.APPLIED);});}
|
||||
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 record TrophyRedemption(TrophyRedeemStatus status,List<FishingState.Allocation> allocations){
|
||||
public TrophyRedemption{allocations=List.copyOf(allocations);}
|
||||
}
|
||||
public CompletableFuture<TrophyRedeemStatus> redeemTrophy(UUID id,UUID carrier){
|
||||
return redeemTrophyWithEvidence(id,carrier).thenApply(TrophyRedemption::status);
|
||||
}
|
||||
public CompletableFuture<TrophyRedemption> redeemTrophyWithEvidence(UUID id,UUID carrier){
|
||||
Objects.requireNonNull(id);Objects.requireNonNull(carrier);
|
||||
return mutate(state->{
|
||||
if(state.trophyReceipts().contains(id))
|
||||
return result(state,new TrophyRedemption(TrophyRedeemStatus.REDEEMED,List.of()));
|
||||
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,new TrophyRedemption(TrophyRedeemStatus.NOT_FOUND,List.of()));
|
||||
if(!trophy.carrier().equals(carrier))
|
||||
return result(state,new TrophyRedemption(TrophyRedeemStatus.WRONG_CARRIER,List.of()));
|
||||
var allocations=new ArrayList<>(state.allocations());
|
||||
var created=new ArrayList<FishingState.Allocation>();
|
||||
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());
|
||||
var existing=allocations.stream().filter(value->value.id().equals(allocation)).findFirst().orElse(null);
|
||||
if(existing==null){
|
||||
existing=new FishingState.Allocation(allocation,trophy.id(),trophy.catalog(),trophy.site(),
|
||||
entry.getKey(),reward.xp(),reward.coins(),
|
||||
trophy.type().name().toLowerCase(java.util.Locale.ROOT)+"_trophy");
|
||||
allocations.add(existing);
|
||||
}
|
||||
created.add(existing);
|
||||
}
|
||||
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),new TrophyRedemption(TrophyRedeemStatus.APPLIED,created));
|
||||
});
|
||||
}
|
||||
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 completeCatchWithEvidence(operation).thenApply(CatchReceipt::status);}
|
||||
@@ -69,7 +104,7 @@ public final class FishingDirectory implements AutoCloseable {
|
||||
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()));
|
||||
submission.catalog(),submission.site(),entry.getKey(),reward.xp(),reward.coins(),"fishing"));
|
||||
}
|
||||
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()),
|
||||
|
||||
@@ -17,11 +17,14 @@ public record FishingState(List<ItemStack> inventory,List<Trophy> trophies,Set<U
|
||||
}
|
||||
public enum TrophyType { CHAMPION, CAMP, DUNGEON }
|
||||
public enum TrophyPhase { PREPARED, EARNED }
|
||||
public record Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards,TrophyPhase phase) {
|
||||
public Trophy {Objects.requireNonNull(id);Objects.requireNonNull(type);Objects.requireNonNull(carrier);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(quest);Objects.requireNonNull(source);name=validName(name);origin=validName(origin);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);if(name.isBlank()||origin.isBlank()||rewards.isEmpty()||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null))throw new IllegalArgumentException("Invalid adventure trophy");}
|
||||
public record Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards,TrophyPhase phase,Map<String,String> audit) {
|
||||
public Trophy {Objects.requireNonNull(id);Objects.requireNonNull(type);Objects.requireNonNull(carrier);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(quest);Objects.requireNonNull(source);name=validName(name);origin=validName(origin);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);audit=Map.copyOf(audit);if(name.isBlank()||origin.isBlank()||rewards.isEmpty()||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null)||audit.size()>32||audit.entrySet().stream().anyMatch(entry->entry.getKey().length()>100||entry.getValue().length()>262144))throw new IllegalArgumentException("Invalid adventure trophy");}
|
||||
public Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards,TrophyPhase phase){this(id,type,carrier,catalog,site,quest,source,name,origin,rewards,phase,Map.of());}
|
||||
public Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards){this(id,type,carrier,catalog,site,quest,source,name,origin,rewards,TrophyPhase.PREPARED);}
|
||||
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 Trophy earned(){return phase==TrophyPhase.EARNED?this:new Trophy(id,type,carrier,catalog,site,quest,source,name,origin,rewards,TrophyPhase.EARNED,audit);}
|
||||
/** Operation-local durable phase revision; not a global character or YAML revision. */
|
||||
public int revision(){return phase==TrophyPhase.PREPARED?1:2;}
|
||||
}
|
||||
public record Catch(UUID id,UUID character,UUID quest,UUID site,UUID item,String itemName,BigInteger xp,
|
||||
boolean itemAwarded,Map<String,String> audit) {
|
||||
@@ -55,8 +58,17 @@ public record FishingState(List<ItemStack> inventory,List<Trophy> trophies,Set<U
|
||||
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) {
|
||||
public Allocation {Objects.requireNonNull(id);Objects.requireNonNull(submission);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0||xp.signum()==0&&coins.signum()==0)throw new IllegalArgumentException("Invalid fishing allocation");}
|
||||
public record Allocation(UUID id,UUID submission,UUID catalog,UUID site,UUID character,BigInteger xp,
|
||||
BigInteger coins,String sourceKind) {
|
||||
public Allocation {Objects.requireNonNull(id);Objects.requireNonNull(submission);Objects.requireNonNull(catalog);
|
||||
Objects.requireNonNull(site);Objects.requireNonNull(character);Objects.requireNonNull(xp);
|
||||
Objects.requireNonNull(coins);Objects.requireNonNull(sourceKind);
|
||||
if(sourceKind.isBlank()||xp.signum()<0||coins.signum()<0||xp.signum()==0&&coins.signum()==0)
|
||||
throw new IllegalArgumentException("Invalid fishing allocation");}
|
||||
/** Old receipts lacked a durable source type; do not invent a trophy category on reload. */
|
||||
public Allocation(UUID id,UUID submission,UUID catalog,UUID site,UUID character,BigInteger xp,BigInteger coins){
|
||||
this(id,submission,catalog,site,character,xp,coins,"unavailable_legacy_allocation");
|
||||
}
|
||||
public Reward reward(){return new Reward(xp,coins);}
|
||||
}
|
||||
public record Claim(UUID id,UUID character,Set<UUID> allocations,BigInteger xp,BigInteger coins) {
|
||||
|
||||
@@ -25,7 +25,8 @@ 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");
|
||||
"adventure.items_submitted", "adventure.reward_claimed",
|
||||
"adventure.trophy_earned", "adventure.trophy_redeemed", "combat.reward_resolved");
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final EquipmentSessions sessions;
|
||||
@@ -126,28 +127,98 @@ public final class FishingEventBridge implements AutoCloseable {
|
||||
} catch (RuntimeException unavailable) { return new Actor("system", null); }
|
||||
}
|
||||
|
||||
/** Reconstruct a champion outcome solely from the saved receipt, including after a prepared retry. */
|
||||
public void championEarned(FishingState.Trophy receipt) {
|
||||
if (receipt.type() != FishingState.TrophyType.CHAMPION
|
||||
|| receipt.phase() != FishingState.TrophyPhase.EARNED) return;
|
||||
var audit = receipt.audit();
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("receipt_id", receipt.id().toString());
|
||||
evidence.put("trophy_type", "champion_trophy");
|
||||
evidence.put("trophy_name", receipt.name());
|
||||
evidence.put("source_name", receipt.origin());
|
||||
evidence.put("carrier_character_id", receipt.carrier().toString());
|
||||
evidence.put("carrier_is_beneficiary", receipt.rewards().containsKey(receipt.carrier()));
|
||||
evidence.put("quest_id", receipt.quest().toString());
|
||||
evidence.put("catalog_id", receipt.catalog().toString());
|
||||
evidence.put("site_id", receipt.site().toString());
|
||||
evidence.put("encounter_id", receipt.source().toString());
|
||||
evidence.put("champion_id", audit.getOrDefault("champion_id", "unavailable_legacy_capture"));
|
||||
evidence.put("defeat_location", savedDefeatLocation(audit));
|
||||
evidence.put("defeated_at", audit.getOrDefault("defeated_at", "unavailable_legacy_capture"));
|
||||
evidence.put("unit_kind", audit.getOrDefault("unit_kind", "unavailable_legacy_capture"));
|
||||
evidence.put("unit_id", audit.getOrDefault("unit_id", "unavailable_legacy_capture"));
|
||||
var roster = audit.get("accepted_roster");
|
||||
evidence.put("accepted_roster", roster == null || roster.isEmpty()
|
||||
? List.of() : List.of(roster.split(",")));
|
||||
evidence.put("accepted_roster_status", roster == null ? "unavailable_legacy_capture" : "captured");
|
||||
evidence.put("reward_xp_pool", receipt.total().xp().toString());
|
||||
evidence.put("reward_coin_pool", receipt.total().coins().toString());
|
||||
evidence.put("phase", "earned");
|
||||
evidence.put("receipt_revision", Integer.toString(receipt.revision()));
|
||||
var beneficiaries = new java.util.ArrayList<Map<String, String>>();
|
||||
receipt.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
|
||||
var share = new LinkedHashMap<String, String>();
|
||||
share.put("character_id", entry.getKey().toString());
|
||||
share.put("allocated_xp", entry.getValue().xp().toString());
|
||||
share.put("allocated_coins", entry.getValue().coins().toString());
|
||||
share.put("allocation_status", entry.getValue().xp().signum() == 0
|
||||
&& entry.getValue().coins().signum() == 0 ? "zero_share_completion" : "positive");
|
||||
beneficiaries.add(share);
|
||||
});
|
||||
evidence.put("beneficiaries", beneficiaries);
|
||||
durable("adventure.trophy_earned", receipt.id(), new Actor("system", null), evidence);
|
||||
}
|
||||
|
||||
private static Map<String, Object> savedDefeatLocation(Map<String, String> audit) {
|
||||
try {
|
||||
if (!audit.containsKey("defeat_world_id")) return Map.of("status", "unavailable");
|
||||
var world = UUID.fromString(audit.get("defeat_world_id"));
|
||||
double x = Double.parseDouble(audit.get("defeat_x"));
|
||||
double y = Double.parseDouble(audit.get("defeat_y"));
|
||||
double z = Double.parseDouble(audit.get("defeat_z"));
|
||||
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z))
|
||||
return Map.of("status", "unavailable");
|
||||
return Map.of("world_id", world.toString(),
|
||||
"world_name", audit.getOrDefault("defeat_world_name", "unavailable"),
|
||||
"x", x, "y", y, "z", z);
|
||||
} catch (RuntimeException unavailable) { return Map.of("status", "unavailable"); }
|
||||
}
|
||||
|
||||
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"
|
||||
String primary = 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) {
|
||||
String secondary = data.get("accepted_roster") instanceof List<?> roster && !roster.isEmpty()
|
||||
? "accepted_roster" : null;
|
||||
if (primary == null) { primary = secondary; secondary = null; }
|
||||
if ((primary == null || ((List<?>)data.get(primary)).size() <= 20)
|
||||
&& (secondary == null || ((List<?>)data.get(secondary)).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 first = (List<?>)data.get(primary);
|
||||
int firstParts = Math.max(1, (first.size() + 19) / 20);
|
||||
int secondParts = secondary == null ? 0 : (((List<?>)data.get(secondary)).size() + 19) / 20;
|
||||
int total = firstParts + secondParts;
|
||||
for (int index = 0; index < total; index++) {
|
||||
String collection = index < firstParts ? primary : secondary;
|
||||
int collectionPart = index < firstParts ? index : index - firstParts;
|
||||
var entries = (List<?>)data.get(collection);
|
||||
int from = collectionPart * 20, to = Math.min(from + 20, entries.size());
|
||||
var part = new LinkedHashMap<String, Object>(data);
|
||||
for (var key : List.of("beneficiaries", "sources", "roster", "accepted_roster"))
|
||||
if (data.get(key) instanceof List<?>) part.put(key, List.of());
|
||||
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());
|
||||
if (collection.equals("beneficiaries") && data.get("roster") instanceof List<?> roster
|
||||
&& roster.size() == entries.size()) part.put("roster", roster.subList(from, to));
|
||||
part.put("linked_collection", collection);
|
||||
part.put("collection_total", entries.size());
|
||||
part.put("collection_part_index", collectionPart);
|
||||
part.put("part_index", index);
|
||||
part.put("part_count", count);
|
||||
part.put("part_count", total);
|
||||
outbox.accepted(input(type, operation, actor, part, index));
|
||||
}
|
||||
} catch (RuntimeException ignored) { /* Audit availability cannot change acknowledged fishing. */ }
|
||||
|
||||
@@ -247,7 +247,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
var experiencePool=capturedRewards.pool(kind,level);
|
||||
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(experiencePool, units, random);
|
||||
var coins = games.dmg.fantasystick.progression.RewardAllocator.allocate(coinPool, units, random);
|
||||
CombatRewardRules.KillPosition restricted = managed.isPresent()||kind == CombatRewardRules.Target.PASSIVE
|
||||
CombatRewardRules.KillPosition restricted = managed.isPresent()
|
||||
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
|
||||
UUID targetReward = defeatedCharacter == null ? target.getUniqueId() : defeatedCharacter;
|
||||
String authority=managed.map(value->value.kind()+":"+value.source()).orElse("ordinary");
|
||||
|
||||
@@ -20,13 +20,13 @@ public final class YamlFishingRepository {
|
||||
var root=map(yaml().load(Files.readString(file,StandardCharsets.UTF_8)));
|
||||
int schema=number(root.get("schema-version")).intValueExact();if(schema<1||schema>2)throw new IllegalArgumentException();
|
||||
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 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"))),strings(row.get("audit"))));}
|
||||
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")),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"))),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 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")),row.get("source-kind") instanceof String kind&&!kind.isBlank()?kind:"unavailable_legacy_allocation"));}
|
||||
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"))));}
|
||||
return new FishingState(inventory,trophies,trophyReceipts,catches,receipts,submissions,completions,allocations,claims);
|
||||
}catch(RuntimeException invalid){throw new IOException("Invalid fishing file",invalid);}
|
||||
@@ -34,13 +34,13 @@ public final class YamlFishingRepository {
|
||||
public void save(FishingState state)throws IOException{
|
||||
Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",2);
|
||||
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("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(),"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("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(),"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(),"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("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(),"source-kind",value.sourceKind())).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());
|
||||
Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"fishing-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic fishing persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ class ChampionQuestNativeTest {
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
@Test void registeredChampionOfferNativeEncounterBossBarAndExactRewardUseConnectedPath() throws Exception {
|
||||
UUID unrelatedOrdinaryId;
|
||||
double redemptionX;
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"adventure-daily-site-count: 1\nadventure-site-reset-seconds: 0\n"+
|
||||
"adventure-champion-health: 100\nadventure-champion-damage: 8\n"+
|
||||
@@ -39,12 +41,250 @@ class ChampionQuestNativeTest {
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","champion","20","60","Summit"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"quest-giver","place","Guide"});drainOne(f);
|
||||
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var current=directory.snapshot();var snapshotField=directory.getClass().getDeclaredField("snapshot");snapshotField.setAccessible(true);snapshotField.set(directory,new games.dmg.fantasystick.adventure.AdventureQuestState(current.sites(),current.givers(),null,current.quests(),current.unitClaims(),current.characterClaims(),current.rosters()));directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.reconcile(chunk);var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow();f.activate(actor);var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(actor.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,parties.invite(actor.core.id,party.id(),member.core.id).orElseThrow().id()));f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drainOne(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var lore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(lore.contains("Champion"));assertTrue(lore.contains("150 XP, 40 coins"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));when(click.isRightClick()).thenReturn(true);controller.click(click);drainOne(f);
|
||||
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(actor.world);f.players.put(solo.core.id,solo.player);f.activate(solo);var catalog=directory.snapshot().catalog();var site=catalog.sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(catalog.revision(),site,games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,solo.core.record.id(),Set.of(solo.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());var soloQuest=directory.activeFor(solo.core.record.id(),null).orElseThrow();assertTrue(directory.bank(soloQuest.id(),UUID.randomUUID(),Map.of(solo.core.record.id(),new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.valueOf(7),java.math.BigInteger.valueOf(3)))).get(5,java.util.concurrent.TimeUnit.SECONDS));var siteRecord=directory.findSite(site).orElseThrow();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(20,60),siteRecord.arena());actor.core.location.setZ(siteRecord.z()+25);member.core.location.setZ(siteRecord.z()+25);solo.core.location.setZ(siteRecord.z()+25);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var initial=storage.find(actor.core.record.id()).get().orElseThrow();var memberInitial=storage.find(member.core.record.id()).get().orElseThrow();var soloInitial=storage.find(solo.core.record.id()).get().orElseThrow();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();var champion=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).findFirst().orElseThrow();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION,controller.encounterOrigin(champion).orElseThrow().kind());verify(maxHealth).setBaseValue(100);verify(attack).setBaseValue(9);verify(champion).setHealth(100);verify(champion).customName(argThat(component->component.toString().contains("Daybreak Champion")));assertEquals(1,f.bossBars.size());verify(f.bossBars.getFirst()).addPlayer(actor.player);verify(f.bossBars.getFirst()).addPlayer(member.player);verify(f.bossBars.getFirst()).addPlayer(solo.player);assertFalse(actor.particleCalls.isEmpty());assertFalse(member.particleCalls.isEmpty());assertFalse(solo.particleCalls.isEmpty());int memberParticles=member.particleCalls.size();member.core.location.setX(100);for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst()).removePlayer(member.player);assertEquals(memberParticles,member.particleCalls.size());member.core.location.setX(siteRecord.x());for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst(),atLeast(2)).addPlayer(member.player);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-health","120"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage","10"});drainOne(f);var liveSettings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");for(int retry=0;retry<10&&liveSettings.rules().championDamage()!=10;retry++)drainOne(f);verify(maxHealth,times(1)).setBaseValue(100);verify(attack,times(1)).setBaseValue(9);
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);verify(f.bossBars.getFirst(),never()).setProgress(0.99);var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());verify(f.bossBars.getFirst()).setProgress(0.99);actor.core.location.setX(100);var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new ArrayList<>(),9);f.dispatcher.accept(death);assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION,controller.encounterOrigin(champion).orElseThrow().kind(),"death processing retains immutable managed origin authority");assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());for(int i=0;i<50&&(directory.active().size()!=1||trophies.trophies(actor.core.record.id()).isEmpty());i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var saved=storage.find(actor.core.record.id()).get().orElseThrow();var savedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(initial.experience(),saved.experience());assertEquals(memberInitial.experience(),savedMember.experience());assertEquals(initial.restrictedKills(),saved.restrictedKills());assertEquals(memberInitial.restrictedKills(),savedMember.restrictedKills());var partyTrophy=trophies.trophies(actor.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CHAMPION,partyTrophy.type());assertEquals("Summit",partyTrophy.origin());assertEquals(java.math.BigInteger.valueOf(150),partyTrophy.total().xp());assertEquals(java.math.BigInteger.valueOf(40),partyTrophy.total().coins());assertFalse(partyTrophy.rewards().containsKey(actor.core.record.id()),"absent leader custody does not invent a beneficiary share");assertTrue(partyTrophy.rewards().containsKey(member.core.record.id()));assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(actor.core.record.id()));assertEquals(1,directory.active().size(),"Unrepresented solo quest remains");verify(f.bossBars.getFirst(),atLeastOnce()).removeAll();int memberParticlesAfterCompletion=member.particleCalls.size();
|
||||
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(actor.world);f.players.put(solo.core.id,solo.player);f.activate(solo);var catalog=directory.snapshot().catalog();var site=catalog.sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(catalog.revision(),site,games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,solo.core.record.id(),Set.of(solo.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());var soloQuest=directory.activeFor(solo.core.record.id(),null).orElseThrow();assertTrue(directory.bank(soloQuest.id(),UUID.randomUUID(),Map.of(solo.core.record.id(),new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.valueOf(7),java.math.BigInteger.valueOf(3)))).get(5,java.util.concurrent.TimeUnit.SECONDS));var siteRecord=directory.findSite(site).orElseThrow();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(20,60),siteRecord.arena());actor.core.location.setZ(siteRecord.z()+25);member.core.location.setZ(siteRecord.z()+25);solo.core.location.setZ(siteRecord.z()+25);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var initial=storage.find(actor.core.record.id()).get().orElseThrow();var memberInitial=storage.find(member.core.record.id()).get().orElseThrow();var soloInitial=storage.find(solo.core.record.id()).get().orElseThrow();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();var champion=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).findFirst().orElseThrow();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION,controller.encounterOrigin(champion).orElseThrow().kind());
|
||||
var unrelatedPassive=mock(org.bukkit.entity.Cow.class);
|
||||
when(unrelatedPassive.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var championData=champion.getPersistentDataContainer();
|
||||
when(unrelatedPassive.getPersistentDataContainer()).thenReturn(championData);
|
||||
assertTrue(controller.encounterOrigin(unrelatedPassive).isEmpty(),
|
||||
"copied tags on an unrelated passive cannot acquire a registered managed origin");
|
||||
var nativeDrop=new org.bukkit.inventory.ItemStack(Material.BEEF);
|
||||
var foreignDeath=new org.bukkit.event.entity.EntityDeathEvent(unrelatedPassive,
|
||||
mock(org.bukkit.damage.DamageSource.class),new ArrayList<>(List.of(nativeDrop)),7);
|
||||
f.dispatcher.accept(foreignDeath);
|
||||
assertEquals(7,foreignDeath.getDroppedExp(),"unowned passive keeps native XP");
|
||||
assertEquals(List.of(nativeDrop),foreignDeath.getDrops(),"unowned passive keeps native loot");
|
||||
verify(maxHealth).setBaseValue(100);verify(attack).setBaseValue(9);verify(champion).setHealth(100);verify(champion).customName(argThat(component->component.toString().contains("Daybreak Champion")));assertEquals(1,f.bossBars.size());verify(f.bossBars.getFirst()).addPlayer(actor.player);verify(f.bossBars.getFirst()).addPlayer(member.player);verify(f.bossBars.getFirst()).addPlayer(solo.player);assertFalse(actor.particleCalls.isEmpty());assertFalse(member.particleCalls.isEmpty());assertFalse(solo.particleCalls.isEmpty());int memberParticles=member.particleCalls.size();member.core.location.setX(100);for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst()).removePlayer(member.player);assertEquals(memberParticles,member.particleCalls.size());member.core.location.setX(siteRecord.x());for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst(),atLeast(2)).addPlayer(member.player);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-health","120"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage","10"});drainOne(f);var liveSettings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");for(int retry=0;retry<10&&liveSettings.rules().championDamage()!=10;retry++)drainOne(f);verify(maxHealth,times(1)).setBaseValue(100);verify(attack,times(1)).setBaseValue(9);
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);verify(f.bossBars.getFirst(),never()).setProgress(0.99);var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());verify(f.bossBars.getFirst()).setProgress(0.99);actor.core.location.setX(100);var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new ArrayList<>(),9);f.dispatcher.accept(death);assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION,controller.encounterOrigin(champion).orElseThrow().kind(),"death processing retains immutable managed origin authority");assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());for(int i=0;i<50&&(directory.active().size()!=1||trophies.trophies(actor.core.record.id()).isEmpty());i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var saved=storage.find(actor.core.record.id()).get().orElseThrow();var savedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(initial.experience(),saved.experience());assertEquals(memberInitial.experience(),savedMember.experience());assertEquals(initial.restrictedKills(),saved.restrictedKills());assertEquals(memberInitial.restrictedKills(),savedMember.restrictedKills());var partyTrophy=trophies.trophies(actor.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CHAMPION,partyTrophy.type());assertEquals("Summit",partyTrophy.origin());
|
||||
assertEquals(champion.getUniqueId().toString(),partyTrophy.audit().get("champion_id"));
|
||||
assertEquals("party",partyTrophy.audit().get("unit_kind"));
|
||||
assertEquals(2,partyTrophy.audit().get("accepted_roster").split(",").length);
|
||||
assertEquals(java.math.BigInteger.valueOf(150),partyTrophy.total().xp());assertEquals(java.math.BigInteger.valueOf(40),partyTrophy.total().coins());assertFalse(partyTrophy.rewards().containsKey(actor.core.record.id()),"absent leader custody does not invent a beneficiary share");assertTrue(partyTrophy.rewards().containsKey(member.core.record.id()));assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(actor.core.record.id()));assertEquals(1,directory.active().size(),"Unrepresented solo quest remains");
|
||||
var ordinaryActor=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
ordinaryActor.core.location.setWorld(actor.world);
|
||||
f.players.put(ordinaryActor.core.id,ordinaryActor.player);f.activate(ordinaryActor);
|
||||
var unrelatedInArena=mock(org.bukkit.entity.Cow.class);
|
||||
unrelatedOrdinaryId=UUID.randomUUID();when(unrelatedInArena.getUniqueId()).thenReturn(unrelatedOrdinaryId);
|
||||
when(unrelatedInArena.getPersistentDataContainer()).thenReturn(championData);
|
||||
when(unrelatedInArena.getType()).thenReturn(org.bukkit.entity.EntityType.COW);
|
||||
when(unrelatedInArena.getWorld()).thenReturn(actor.world);
|
||||
when(unrelatedInArena.getLocation()).thenReturn(new Location(actor.world,siteRecord.x()+5,64,siteRecord.z()+25));
|
||||
assertTrue(controller.encounterOrigin(unrelatedInArena).isEmpty(),
|
||||
"a copied tag near the next registered encounter cannot bypass ordinary spacing");
|
||||
var ordinarySource=mock(org.bukkit.damage.DamageSource.class);
|
||||
when(ordinarySource.getCausingEntity()).thenReturn(ordinaryActor.player);
|
||||
when(ordinarySource.getDirectEntity()).thenReturn(ordinaryActor.player);
|
||||
var ordinaryHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(ordinaryActor.player,
|
||||
unrelatedInArena,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,ordinarySource,4);
|
||||
f.dispatcher.accept(ordinaryHit);assertFalse(ordinaryHit.isCancelled());
|
||||
var ordinaryDrop=new org.bukkit.inventory.ItemStack(Material.BEEF);
|
||||
var ordinaryDeath=new org.bukkit.event.entity.EntityDeathEvent(unrelatedInArena,ordinarySource,
|
||||
new ArrayList<>(List.of(ordinaryDrop)),5);
|
||||
f.dispatcher.accept(ordinaryDeath);
|
||||
for(int attempt=0;attempt<100&&storage.find(ordinaryActor.core.record.id()).get().orElseThrow()
|
||||
.experience().equals(java.math.BigInteger.ZERO);attempt++){
|
||||
var task=f.tasks.poll(20,java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
if(task!=null)task.run();Thread.sleep(5);
|
||||
}
|
||||
assertEquals(List.of(ordinaryDrop),ordinaryDeath.getDrops());
|
||||
assertEquals(5,ordinaryDeath.getDroppedExp());
|
||||
assertEquals(java.math.BigInteger.ONE,
|
||||
storage.find(ordinaryActor.core.record.id()).get().orElseThrow().experience());
|
||||
assertTrue(storage.find(ordinaryActor.core.record.id()).get().orElseThrow().restrictedKills()
|
||||
.containsKey(worldId));
|
||||
assertEquals(initial.experience(),storage.find(actor.core.record.id()).get().orElseThrow().experience(),
|
||||
"an unrelated ordinary passive never pays the nonparticipating trophy carrier");
|
||||
verify(f.bossBars.getFirst(),atLeastOnce()).removeAll();int memberParticlesAfterCompletion=member.particleCalls.size();
|
||||
awaitPulse(f,()->spawned.stream().filter(Vindicator.class::isInstance).count()>=2);assertEquals(memberParticlesAfterCompletion,member.particleCalls.size(),"completed party members stop receiving the shared boundary");var second=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).skip(1).findFirst().orElseThrow();assertEquals(2,f.bossBars.size());verify(maxHealth).setBaseValue(120);verify(attack).setBaseValue(10);verify(second).setHealth(120);var soloSource=mock(org.bukkit.damage.DamageSource.class);when(soloSource.getCausingEntity()).thenReturn(solo.player);when(soloSource.getDirectEntity()).thenReturn(solo.player);f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(solo.player,second,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,soloSource,1));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(second,soloSource,new ArrayList<>(),0));await(f,()->directory.active().isEmpty()&&!trophies.trophies(solo.core.record.id()).isEmpty());var savedSolo=storage.find(solo.core.record.id()).get().orElseThrow();assertEquals(soloInitial.experience(),savedSolo.experience());assertEquals(soloInitial.coins(),savedSolo.coins());var soloTrophy=trophies.trophies(solo.core.record.id()).getFirst();assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(150),java.math.BigInteger.valueOf(40)),soloTrophy.total());assertEquals(solo.core.record.id(),soloTrophy.carrier());assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(solo.core.record.id()));verify(f.bossBars.get(1),atLeastOnce()).removeAll();
|
||||
PartyBookNativeTest.open(f,actor);var inventoryText=PartyBookNativeTest.text(actor).replace("\n","");assertTrue(inventoryText.contains("Daybreak Champion Head: 1"),inventoryText);assertTrue(inventoryText.contains("Trophy origin: Summit"),inventoryText);
|
||||
var reroll=directory.rerollToday(catalog.revision()).get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(reroll.catalog().isPresent());assertTrue(controller.publishPendingReset().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS));assertTrue(directory.removeSite(site).get(5,java.util.concurrent.TimeUnit.SECONDS).isPresent());assertTrue(parties.disband(actor.core.id,party.id()));doReturn(false).when(member.player).isOnline();
|
||||
var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");turn.open(actor.player);var turnMenu=actor.inventories.getLast();var redeem=menuItem(turnMenu,"Redeem Daybreak Champion Head");assertTrue(redeem.getItemMeta().lore().toString().contains("Beneficiaries: 1"));click(turn,actor,turnMenu,redeem);await(f,()->trophies.trophies(actor.core.record.id()).isEmpty()&&trophies.unclaimed(member.core.record.id()).xp().equals(java.math.BigInteger.valueOf(150)));var memberShare=trophies.unclaimed(member.core.record.id());assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(actor.core.record.id()),"carrier custody does not claim or invent a share");assertEquals(memberShare,trophies.unclaimed(member.core.record.id()),"offline beneficiary retains the full frozen pool");doReturn(true).when(member.player).isOnline();turn.open(member.player);var memberTurn=member.inventories.getLast();click(turn,member,memberTurn,menuItem(memberTurn,"Claim earned quest rewards"));await(f,()->trophies.unclaimed(member.core.record.id()).equals(games.dmg.fantasystick.adventure.FishingState.Reward.empty()));var claimedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(memberInitial.experience().add(java.math.BigInteger.valueOf(150)),claimedMember.experience());assertEquals(memberInitial.coins().add(java.math.BigInteger.valueOf(40)),claimedMember.coins());turn.open(solo.player);var soloTurn=solo.inventories.getLast();click(turn,solo,soloTurn,menuItem(soloTurn,"Claim retained adventure rewards"));await(f,()->directory.unclaimedAll(solo.core.record.id()).equals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward.empty()));assertEquals(1,trophies.trophies(solo.core.record.id()).size(),"legacy claims do not consume an earned trophy");
|
||||
var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");actor.core.location.setX(siteRecord.x());actor.core.location.setZ(siteRecord.z());
|
||||
int priorOffers=actor.inventories.size();
|
||||
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));
|
||||
await(f,()->actor.inventories.size()>priorOffers);
|
||||
var giverOffers=actor.inventories.getLast();var giverEntry=menuItem(giverOffers,"Turn in quest items");
|
||||
var giverClick=mock(InventoryClickEvent.class);when(giverClick.getWhoClicked()).thenReturn(actor.player);
|
||||
when(giverClick.getInventory()).thenReturn(giverOffers);when(giverClick.getCurrentItem()).thenReturn(giverEntry);
|
||||
controller.click(giverClick);var turnMenu=actor.inventories.getLast();var redeem=menuItem(turnMenu,"Redeem Daybreak Champion Head");assertTrue(redeem.getItemMeta().lore().toString().contains("Beneficiaries: 1"));
|
||||
var fishingFile=data.resolve("fishing.yml");var savedFishing=java.nio.file.Files.readAllBytes(fishingFile);
|
||||
java.nio.file.Files.delete(fishingFile);java.nio.file.Files.createDirectory(fishingFile);
|
||||
click(turn,actor,turnMenu,redeem);
|
||||
await(f,()->actor.messages.stream().anyMatch(value->value.contains("Trophy redemption failed")));
|
||||
assertEquals(1,trophies.trophies(actor.core.record.id()).size());
|
||||
assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),
|
||||
trophies.unclaimed(member.core.record.id()));
|
||||
assertEquals(0,exportedCount(data,"adventure.trophy_redeemed"),
|
||||
"failed redemption cannot attest receipt consumption");
|
||||
java.nio.file.Files.delete(fishingFile);java.nio.file.Files.write(fishingFile,savedFishing);
|
||||
int beforeRetryOffers=actor.inventories.size();
|
||||
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));
|
||||
await(f,()->actor.inventories.size()>beforeRetryOffers);
|
||||
var retryOffers=actor.inventories.getLast();var retryEntry=menuItem(retryOffers,"Turn in quest items");
|
||||
var retryClick=mock(InventoryClickEvent.class);when(retryClick.getWhoClicked()).thenReturn(actor.player);
|
||||
when(retryClick.getInventory()).thenReturn(retryOffers);
|
||||
when(retryClick.getCurrentItem()).thenReturn(retryEntry);
|
||||
controller.click(retryClick);var retryMenu=actor.inventories.getLast();
|
||||
redemptionX=actor.core.location.getX();
|
||||
click(turn,actor,retryMenu,menuItem(retryMenu,"Redeem Daybreak Champion Head"));
|
||||
actor.core.location.setX(redemptionX+100);
|
||||
await(f,()->trophies.trophies(actor.core.record.id()).isEmpty()&&trophies.unclaimed(member.core.record.id()).xp().equals(java.math.BigInteger.valueOf(150)));var memberShare=trophies.unclaimed(member.core.record.id());assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(actor.core.record.id()),"carrier custody does not claim or invent a share");assertEquals(memberShare,trophies.unclaimed(member.core.record.id()),"offline beneficiary retains the full frozen pool");doReturn(true).when(member.player).isOnline();member.core.location.setZ(siteRecord.z());
|
||||
int beforeMemberOffers=member.inventories.size();
|
||||
f.dispatcher.accept(new PlayerInteractEntityEvent(member.player,giver,EquipmentSlot.HAND));
|
||||
await(f,()->member.inventories.size()>beforeMemberOffers);
|
||||
var memberOffers=member.inventories.getLast();var memberEntry=menuItem(memberOffers,"Turn in quest items");
|
||||
var memberEntryClick=mock(InventoryClickEvent.class);when(memberEntryClick.getWhoClicked()).thenReturn(member.player);
|
||||
when(memberEntryClick.getInventory()).thenReturn(memberOffers);
|
||||
when(memberEntryClick.getCurrentItem()).thenReturn(memberEntry);
|
||||
controller.click(memberEntryClick);var memberTurn=member.inventories.getLast();click(turn,member,memberTurn,menuItem(memberTurn,"Claim earned quest rewards"));await(f,()->trophies.unclaimed(member.core.record.id()).equals(games.dmg.fantasystick.adventure.FishingState.Reward.empty()));var claimedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(memberInitial.experience().add(java.math.BigInteger.valueOf(150)),claimedMember.experience());assertEquals(memberInitial.coins().add(java.math.BigInteger.valueOf(40)),claimedMember.coins());turn.open(solo.player);var soloTurn=solo.inventories.getLast();click(turn,solo,soloTurn,menuItem(soloTurn,"Claim retained adventure rewards"));await(f,()->directory.unclaimedAll(solo.core.record.id()).equals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward.empty()));assertEquals(1,trophies.trophies(solo.core.record.id()).size(),"legacy claims do not consume an earned trophy");
|
||||
for(int attempt=0;attempt<100&&(exportedCount(data,"adventure.trophy_earned")<2
|
||||
||exportedCount(data,"adventure.trophy_redeemed")<1
|
||||
||exportedCount(data,"adventure.reward_claimed")<1);attempt++){
|
||||
var task=f.tasks.poll(20,java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
if(task!=null)task.run();Thread.sleep(5);
|
||||
}
|
||||
assertEquals(2,exportedCount(data,"adventure.trophy_earned"),
|
||||
"redeemed="+exportedCount(data,"adventure.trophy_redeemed")
|
||||
+" claims="+exportedCount(data,"adventure.reward_claimed"));
|
||||
assertEquals(1,exportedCount(data,"adventure.trophy_redeemed"));
|
||||
assertTrue(exportedCount(data,"adventure.reward_claimed")>=1);
|
||||
}
|
||||
var exported=exported(data);
|
||||
assertEquals(2,exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("adventure.trophy_earned")).count());
|
||||
var redemptions=exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("adventure.trophy_redeemed")).toList();
|
||||
assertEquals(1,redemptions.size());
|
||||
assertEquals("entered_via_registered_giver",redemptions.getFirst().getAsJsonObject("data")
|
||||
.get("giver_status").getAsString());
|
||||
var earned=exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("adventure.trophy_earned")).toList();
|
||||
assertEquals(2,earned.stream().map(event->event.get("id").getAsString()).distinct().count());
|
||||
assertEquals(2,earned.stream().map(event->event.getAsJsonObject("data")
|
||||
.get("receipt_id").getAsString()).distinct().count(),
|
||||
"identically named heads from separate native encounters retain distinct receipts");
|
||||
assertEquals(1,earned.stream().map(event->event.getAsJsonObject("data")
|
||||
.get("trophy_name").getAsString()).distinct().count());
|
||||
assertTrue(earned.stream().anyMatch(event->!event.getAsJsonObject("data")
|
||||
.get("carrier_is_beneficiary").getAsBoolean()));
|
||||
assertTrue(earned.stream().allMatch(event->event.getAsJsonObject("data")
|
||||
.get("receipt_revision").getAsString().equals("2")),
|
||||
"prepared revision 1 becomes durable earned revision 2 exactly once");
|
||||
var partyEarned=earned.stream().map(event->event.getAsJsonObject("data"))
|
||||
.filter(value->!value.get("carrier_is_beneficiary").getAsBoolean()).findFirst().orElseThrow();
|
||||
assertEquals("party",partyEarned.get("unit_kind").getAsString());
|
||||
assertEquals("150",partyEarned.get("reward_xp_pool").getAsString());
|
||||
assertEquals(1,partyEarned.getAsJsonArray("beneficiaries").size());
|
||||
assertEquals(2,partyEarned.getAsJsonArray("accepted_roster").size());
|
||||
assertTrue(partyEarned.getAsJsonObject("defeat_location").has("world_id"));
|
||||
assertEquals(partyEarned.get("receipt_id").getAsString(),redemptions.getFirst()
|
||||
.getAsJsonObject("data").get("receipt_id").getAsString());
|
||||
assertEquals(redemptionX,redemptions.getFirst().getAsJsonObject("data")
|
||||
.getAsJsonObject("redeemer_location").get("x").getAsDouble(),
|
||||
"redemption reports the native click position rather than a later callback location");
|
||||
var suppressed=exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("combat.reward_resolved")&&event.getAsJsonObject("data").has("resolution")
|
||||
&&event.getAsJsonObject("data").get("resolution").getAsString()
|
||||
.equals("encounter_suppressed")).toList();
|
||||
assertEquals(2,suppressed.size(),"registered champion enemies suppress per-kill payout without history updates");
|
||||
var unrelatedReward=exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("combat.reward_resolved")&&event.getAsJsonObject("data").has("target_id")
|
||||
&&event.getAsJsonObject("data").get("target_id").getAsString()
|
||||
.equals(unrelatedOrdinaryId.toString())).findFirst().orElseThrow();
|
||||
assertEquals("ordinary_spacing_eligible",unrelatedReward.getAsJsonObject("data")
|
||||
.get("resolution").getAsString());
|
||||
assertEquals("1",unrelatedReward.getAsJsonObject("data").get("applied_experience").getAsString());
|
||||
assertFalse(unrelatedReward.getAsJsonObject("data").has("managed_origin_kind"));
|
||||
assertTrue(suppressed.stream().allMatch(event->event.getAsJsonObject("data")
|
||||
.get("managed_origin_kind").getAsString().equals("champion")));
|
||||
var claim=exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("adventure.reward_claimed")).findFirst().orElseThrow();
|
||||
assertEquals("champion_trophy",claim.getAsJsonObject("data").getAsJsonArray("sources")
|
||||
.get(0).getAsJsonObject().get("source_kind").getAsString());
|
||||
assertEquals("entered_via_registered_giver",claim.getAsJsonObject("data")
|
||||
.get("giver_status").getAsString());
|
||||
}
|
||||
|
||||
@Test void preparedChampionRecoveryPublishesOnlyTheCapturedReceiptAfterRestart() throws Exception {
|
||||
UUID trophyId=UUID.randomUUID(),championId=UUID.randomUUID();
|
||||
UUID carrier;
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id,actor.player);f.activate(actor);
|
||||
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","champion","20","60","Restart Peak"});
|
||||
drainOne(f);
|
||||
var quests=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
|
||||
quests.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var catalog=quests.snapshot().catalog();var site=catalog.sites().getFirst();
|
||||
carrier=actor.core.record.id();
|
||||
var accepted=quests.accept(catalog.revision(),site,
|
||||
games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,
|
||||
carrier,Set.of(carrier)).get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,accepted.status());
|
||||
var quest=quests.activeFor(carrier,null).orElseThrow();
|
||||
var plan=new games.dmg.fantasystick.adventure.FishingState.Trophy(trophyId,
|
||||
games.dmg.fantasystick.adventure.FishingState.TrophyType.CHAMPION,carrier,
|
||||
quest.catalog(),quest.site(),quest.id(),UUID.randomUUID(),"Daybreak Champion Head",
|
||||
"Restart Peak",Map.of(carrier,new games.dmg.fantasystick.adventure.FishingState.Reward(
|
||||
java.math.BigInteger.valueOf(150),java.math.BigInteger.valueOf(40))),
|
||||
games.dmg.fantasystick.adventure.FishingState.TrophyPhase.PREPARED,
|
||||
Map.of("champion_id",championId.toString(),"defeat_status","unavailable",
|
||||
"defeated_at","unavailable","unit_kind","solo","unit_id",carrier.toString(),
|
||||
"accepted_roster",carrier.toString()));
|
||||
var fishing=(games.dmg.fantasystick.adventure.FishingDirectory)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
|
||||
assertEquals(games.dmg.fantasystick.adventure.FishingDirectory.TrophyPrepareStatus.APPLIED,
|
||||
fishing.prepareTrophy(plan).get(5,java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertEquals(0,exportedCount(data,"adventure.trophy_earned"),
|
||||
"a prepared receipt is not an earned event");
|
||||
var file=data.resolve("fishing.yml");var retained=java.nio.file.Files.readAllBytes(file);
|
||||
java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
|
||||
var controller=(games.dmg.fantasystick.adventure.FishingController)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,()->controller
|
||||
.earnTrophyWithEvidence(plan).toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertEquals(0,exportedCount(data,"adventure.trophy_earned"),
|
||||
"failed commit does not attest earned trophy");
|
||||
java.nio.file.Files.delete(file);java.nio.file.Files.write(file,retained);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
try(var restarted=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
restarted.plugin.setEnabled(true);
|
||||
var fishing=(games.dmg.fantasystick.adventure.FishingDirectory)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(restarted.plugin,"fishing");
|
||||
for(int attempt=0;attempt<100&&(fishing.trophies(carrier).isEmpty()
|
||||
||exportedCount(data,"adventure.trophy_earned")<1);attempt++){
|
||||
var task=restarted.tasks.poll(20,java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
if(task!=null)task.run();Thread.sleep(5);
|
||||
}
|
||||
assertEquals(1,fishing.trophies(carrier).size());
|
||||
var earned=exported(data).stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("adventure.trophy_earned")).toList();
|
||||
assertEquals(1,earned.size(),"prepared recovery exports one durable earned event");
|
||||
assertEquals(trophyId.toString(),earned.getFirst().getAsJsonObject("data")
|
||||
.get("receipt_id").getAsString());
|
||||
assertEquals(championId.toString(),earned.getFirst().getAsJsonObject("data")
|
||||
.get("champion_id").getAsString());
|
||||
restarted.plugin.setEnabled(false);
|
||||
}
|
||||
try(var again=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
again.plugin.setEnabled(true);
|
||||
assertEquals(1,exportedCount(data,"adventure.trophy_earned"),
|
||||
"already earned receipts do not publish an invented second event");
|
||||
again.plugin.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static long exportedCount(Path root,String suffix){
|
||||
try{return exported(root).stream().filter(value->value.get("type").getAsString().endsWith(suffix)).count();}
|
||||
catch(Exception unavailable){return 0;}
|
||||
}
|
||||
private static List<com.google.gson.JsonObject> exported(Path root)throws Exception{
|
||||
try(var paths=java.nio.file.Files.walk(root.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()).toList();
|
||||
}
|
||||
}
|
||||
private static org.bukkit.inventory.ItemStack menuItem(org.bukkit.inventory.Inventory menu,String name){var plain=net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();return java.util.stream.IntStream.range(0,menu.getSize()).mapToObj(menu::getItem).filter(java.util.Objects::nonNull).filter(item->item.getItemMeta().displayName()!=null&&plain.serialize(item.getItemMeta().displayName()).equals(name)).findFirst().orElseThrow();}
|
||||
|
||||
@@ -301,10 +301,12 @@ class CombatExperienceNativeTest {
|
||||
assertEquals(beforePassiveCoins, record(f, a).coins().add(record(f, c).coins()));
|
||||
var passiveA = record(f, a).experience();
|
||||
var passiveC = record(f, c).experience();
|
||||
assertEquals(java.math.BigInteger.valueOf(11), passiveA.add(passiveC),
|
||||
"The one-XP passive pool is conserved across the two solo units");
|
||||
assertEquals(1, java.util.stream.Stream.of(passiveA.subtract(java.math.BigInteger.TEN), passiveC)
|
||||
.filter(java.math.BigInteger.ONE::equals).count(), "Exactly one random unit receives the remainder");
|
||||
assertEquals(java.math.BigInteger.TEN, passiveA,
|
||||
"A's passive share is blocked at the prior neutral kill position, even if randomly allocated");
|
||||
assertTrue(passiveC.equals(java.math.BigInteger.ZERO)||passiveC.equals(java.math.BigInteger.ONE),
|
||||
"C can earn only the single randomly assigned passive XP, never passive coins");
|
||||
assertEquals(new java.math.BigDecimal("2.0"),record(f,a).restrictedKills()
|
||||
.get(world.getUID()).x(),"blocked passive attempts update the ordinary reference");
|
||||
assertEquals(java.math.BigInteger.ZERO, record(f, b).experience());
|
||||
|
||||
var exact = mob(EntityType.ZOMBIE, new Location(world, 5, 64, 0));
|
||||
@@ -312,15 +314,175 @@ class CombatExperienceNativeTest {
|
||||
hit(f, a.player, exact, true); // Cancelled repeats do not create another contributor.
|
||||
die(f, exact); settle(f);
|
||||
assertEquals(passiveA.add(java.math.BigInteger.TEN), record(f, a).experience(),
|
||||
"Passive kill did not reset history; exact three-block distance from blocked kill is eligible");
|
||||
"The exact three-block distance from a blocked passive attempt is eligible");
|
||||
assertEquals(new java.math.BigDecimal("5.0"), record(f, a).restrictedKills().values().iterator().next().x());
|
||||
|
||||
var repeatedPassive = mob(EntityType.SHEEP, new Location(world, 2, 64, 0));
|
||||
hit(f, a.player, repeatedPassive, false); die(f, repeatedPassive); settle(f);
|
||||
assertEquals(passiveA.add(java.math.BigInteger.valueOf(11)), record(f, a).experience(), "Same-location passive kills remain eligible");
|
||||
assertEquals(passiveA.add(java.math.BigInteger.valueOf(11)), record(f, a).experience(),
|
||||
"A passive three blocks from the intervening hostile kill is eligible");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ordinaryPassiveKillsSharePerWorldSpacingHistoryWithOtherMobCategories() throws Exception {
|
||||
UUID character,worldId,otherWorldId;
|
||||
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 world=mock(org.bukkit.World.class);var id=UUID.randomUUID();when(world.getUID()).thenReturn(id);
|
||||
character=actor.core.record.id();worldId=id;
|
||||
var initial=record(f,actor);var first=mob(EntityType.COW,new Location(world,0,64,0));
|
||||
hit(f,actor.player,first,false);die(f,first);settle(f);
|
||||
assertEquals(initial.experience().add(java.math.BigInteger.ONE),record(f,actor).experience());
|
||||
assertEquals(new java.math.BigDecimal("0.0"),record(f,actor).restrictedKills().get(id).x());
|
||||
var blocked=mob(EntityType.SHEEP,new Location(world,2,64,0));
|
||||
hit(f,actor.player,blocked,false);die(f,blocked);settle(f);
|
||||
assertEquals(initial.experience().add(java.math.BigInteger.ONE),record(f,actor).experience());
|
||||
assertEquals(new java.math.BigDecimal("2.0"),record(f,actor).restrictedKills().get(id).x(),
|
||||
"a blocked passive advances ordinary spacing history");
|
||||
var eligible=mob(EntityType.ZOMBIE,new Location(world,5,64,0));
|
||||
hit(f,actor.player,eligible,false);die(f,eligible);settle(f);
|
||||
assertEquals(initial.experience().add(java.math.BigInteger.valueOf(11)),record(f,actor).experience());
|
||||
assertEquals(new java.math.BigDecimal("5.0"),record(f,actor).restrictedKills().get(id).x());
|
||||
assertTrue(actor.messages.stream().anyMatch(message->message.contains("too close")));
|
||||
assertTrue(actor.messages.stream().anyMatch(message->message.contains("1")&&message.contains("XP")));
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,actor);
|
||||
assertTrue(PartyBookNativeTest.text(actor).contains("XP: 11/100"));
|
||||
var afterHostileCoins=record(f,actor).coins();
|
||||
var otherWorld=mock(org.bukkit.World.class);otherWorldId=UUID.randomUUID();
|
||||
when(otherWorld.getUID()).thenReturn(otherWorldId);
|
||||
var acrossWorld=mob(EntityType.SHEEP,new Location(otherWorld,0,64,0));
|
||||
hit(f,actor.player,acrossWorld,false);die(f,acrossWorld);settle(f);
|
||||
assertEquals(initial.experience().add(java.math.BigInteger.valueOf(12)),
|
||||
record(f,actor).experience(),"first passive in a new world has independent spacing");
|
||||
var another=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(another.core.id,another.player);f.activate(another);
|
||||
var samePlace=mob(EntityType.COW,new Location(world,5,64,0));
|
||||
hit(f,another.player,samePlace,false);die(f,samePlace);settle(f);
|
||||
assertEquals(java.math.BigInteger.ONE,record(f,another).experience(),
|
||||
"another character has independent ordinary history at the same position");
|
||||
assertEquals(afterHostileCoins,record(f,actor).coins(),"passive kills mint no coins");
|
||||
}
|
||||
var saved=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data)
|
||||
.load(character).orElseThrow();
|
||||
assertEquals(java.math.BigInteger.valueOf(12),saved.experience());
|
||||
assertEquals(new java.math.BigDecimal("5.0"),saved.restrictedKills().get(worldId).x());
|
||||
assertEquals(new java.math.BigDecimal("0.0"),saved.restrictedKills().get(otherWorldId).x());
|
||||
}
|
||||
|
||||
@Test void cappedPassiveCharacterStillConsumesOrdinaryHistoryWithoutCoinOrXp() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"maximum-level: 1\n");
|
||||
UUID character,worldId;
|
||||
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);
|
||||
character=actor.core.record.id();
|
||||
var world=mock(org.bukkit.World.class);worldId=UUID.randomUUID();
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
var before=record(f,actor);
|
||||
var first=mob(EntityType.COW,new Location(world,0,64,0));
|
||||
hit(f,actor.player,first,false);die(f,first);settle(f);
|
||||
var capped=record(f,actor);
|
||||
assertEquals(before.experience(),capped.experience());
|
||||
assertEquals(before.coins(),capped.coins());
|
||||
assertEquals(new java.math.BigDecimal("0.0"),capped.restrictedKills().get(worldId).x());
|
||||
var blocked=mob(EntityType.SHEEP,new Location(world,1,64,0));
|
||||
hit(f,actor.player,blocked,false);die(f,blocked);settle(f);
|
||||
var after=record(f,actor);
|
||||
assertEquals(before.experience(),after.experience());
|
||||
assertEquals(before.coins(),after.coins());
|
||||
assertEquals(new java.math.BigDecimal("1.0"),after.restrictedKills().get(worldId).x());
|
||||
}
|
||||
var reloaded=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data)
|
||||
.load(character).orElseThrow();
|
||||
assertEquals(new java.math.BigDecimal("1.0"),reloaded.restrictedKills().get(worldId).x());
|
||||
var evidence=events(data).stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("combat.reward_resolved")).map(event->event.getAsJsonObject("data")).toList();
|
||||
assertEquals(2,evidence.size());
|
||||
assertEquals("at_cap",evidence.getFirst().get("storage_status").getAsString());
|
||||
assertEquals("ordinary_spacing_blocked",evidence.getLast().get("resolution").getAsString());
|
||||
assertTrue(evidence.stream().allMatch(value->value.get("applied_experience").getAsString()
|
||||
.equals("0")&&value.get("applied_coins").getAsString().equals("0")));
|
||||
}
|
||||
|
||||
@Test void eachCapturedManagedPassiveOriginBypassesWithoutMovingOrdinaryHistory() throws Exception {
|
||||
for(var kind:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.values()){
|
||||
var directory=data.resolve(kind.name());java.nio.file.Files.createDirectories(directory);
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(directory)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(directory);
|
||||
f.players.put(actor.core.id,actor.player);f.activate(actor);
|
||||
var combat=(games.dmg.fantasystick.party.CombatExperience)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"combatExperience");
|
||||
var managed=new java.util.HashSet<UUID>();
|
||||
combat.attachManagedOrigins(entity->managed.contains(entity.getUniqueId())
|
||||
?java.util.Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(
|
||||
kind,entity.getUniqueId())):java.util.Optional.empty());
|
||||
var world=mock(org.bukkit.World.class);var worldId=UUID.randomUUID();when(world.getUID()).thenReturn(worldId);
|
||||
var initial=record(f,actor);var ordinary=mob(EntityType.COW,new Location(world,0,64,0));
|
||||
hit(f,actor.player,ordinary,false);die(f,ordinary);settle(f);
|
||||
assertEquals(initial.experience().add(java.math.BigInteger.ONE),record(f,actor).experience());
|
||||
for(int index=0;index<2;index++){
|
||||
var target=mob(EntityType.COW,new Location(world,1,64,0));managed.add(target.getUniqueId());
|
||||
hit(f,actor.player,target,false);die(f,target);settle(f);
|
||||
}
|
||||
assertEquals(initial.experience().add(java.math.BigInteger.valueOf(3)),
|
||||
record(f,actor).experience(),kind+" keeps both independently receipted managed passive rewards");
|
||||
assertEquals(new java.math.BigDecimal("0.0"),record(f,actor).restrictedKills().get(worldId).x(),
|
||||
"managed kills cannot inspect or advance the ordinary history");
|
||||
var unrelated=mob(EntityType.COW,new Location(world,1,64,0));
|
||||
hit(f,actor.player,unrelated,false);die(f,unrelated);settle(f);
|
||||
assertEquals(initial.experience().add(java.math.BigInteger.valueOf(3)),record(f,actor).experience());
|
||||
assertEquals(new java.math.BigDecimal("1.0"),record(f,actor).restrictedKills().get(worldId).x());
|
||||
assertEquals(initial.coins(),record(f,actor).coins(),"passives award no coins");
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var records=events(directory).stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("combat.reward_resolved")).map(event->event.getAsJsonObject("data")).toList();
|
||||
assertEquals(4,records.size(),kind.toString());
|
||||
assertEquals(2,records.stream().filter(value->value.get("resolution").getAsString()
|
||||
.equals("managed_origin_exempt")&&value.get("managed_origin_kind").getAsString()
|
||||
.equals(kind.name().toLowerCase(java.util.Locale.ROOT))).count());
|
||||
assertEquals(1,records.stream().filter(value->value.get("resolution").getAsString()
|
||||
.equals("ordinary_spacing_blocked")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void capturedDungeonChampionAndCampOriginsKeepEveryMobCategoryOutOfOrdinaryHistory() throws Exception {
|
||||
var categories=java.util.List.of(EntityType.COW,EntityType.BEE,EntityType.ZOMBIE,
|
||||
EntityType.WITCH,EntityType.WARDEN,EntityType.WITHER);
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);
|
||||
var combat=(games.dmg.fantasystick.party.CombatExperience)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"combatExperience");
|
||||
var origins=new java.util.HashMap<UUID,games.dmg.fantasystick.progression.ManagedEncounterOrigin>();
|
||||
combat.attachManagedOrigins(entity->java.util.Optional.ofNullable(origins.get(entity.getUniqueId())));
|
||||
for(var kind:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.values())
|
||||
for(var category:categories){
|
||||
var actor=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id,actor.player);f.activate(actor);
|
||||
var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var target=mob(category,new Location(world,0,64,0));
|
||||
origins.put(target.getUniqueId(),new games.dmg.fantasystick.progression.ManagedEncounterOrigin(
|
||||
kind,target.getUniqueId()));
|
||||
var before=record(f,actor);hit(f,actor.player,target,false);die(f,target);settle(f);
|
||||
var after=record(f,actor);
|
||||
assertTrue(after.experience().compareTo(before.experience())>0,kind+" "+category);
|
||||
assertEquals(before.restrictedKills(),after.restrictedKills(),kind+" "+category);
|
||||
if(category==EntityType.COW)assertEquals(before.coins(),after.coins());
|
||||
}
|
||||
}
|
||||
var evidence=events(data).stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("combat.reward_resolved")).map(event->event.getAsJsonObject("data")).toList();
|
||||
assertEquals(18,evidence.size());
|
||||
for(var kind:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.values())
|
||||
for(var category:categories)assertEquals(1,evidence.stream().filter(event->event
|
||||
.get("managed_origin_kind").getAsString().equals(kind.name().toLowerCase(java.util.Locale.ROOT))
|
||||
&&event.get("target_type").getAsString().equals(category.name().toLowerCase(java.util.Locale.ROOT))
|
||||
&&event.get("resolution").getAsString().equals("managed_origin_exempt")
|
||||
&&!event.has("ordinary_history_before")&&!event.has("ordinary_history_after")).count(),
|
||||
kind+" "+category);
|
||||
}
|
||||
|
||||
@Test void representedPartyIsOneUnitAndSharesWithActiveNonAttackingMembers() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
|
||||
@@ -23,6 +23,73 @@ class FishingEventBridgePartsNativeTest {
|
||||
@org.junit.jupiter.api.BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void rejectedChampionIntentReplaysFromFrozenReceiptIntoExportedJsonl() throws Exception {
|
||||
var carrier=UUID.randomUUID();var zero=UUID.randomUUID();var receiptId=UUID.randomUUID();
|
||||
var receipt=new games.dmg.fantasystick.adventure.FishingState.Trophy(receiptId,
|
||||
games.dmg.fantasystick.adventure.FishingState.TrophyType.CHAMPION,carrier,
|
||||
UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),
|
||||
"Daybreak Champion Head","Peak",Map.of(carrier,
|
||||
new games.dmg.fantasystick.adventure.FishingState.Reward(
|
||||
java.math.BigInteger.ONE,java.math.BigInteger.ZERO),zero,
|
||||
games.dmg.fantasystick.adventure.FishingState.Reward.empty()),
|
||||
games.dmg.fantasystick.adventure.FishingState.TrophyPhase.EARNED,
|
||||
Map.of("champion_id",UUID.randomUUID().toString(),"accepted_roster",
|
||||
carrier+","+zero,"unit_kind","party","unit_id",UUID.randomUUID().toString()));
|
||||
var refused=new FishingEventBridge(event->CompletableFuture.completedFuture(
|
||||
LocalEventLog.Delivery.REJECTED),Clock.systemUTC(),data,mock(EquipmentSessions.class),ignored->{});
|
||||
refused.championEarned(receipt);refused.close();refused.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
var log=LocalEventLog.open(data,Clock.systemUTC(),
|
||||
new LocalEventLog.RuntimeInfo("test","test","test"),128,1_000_000,ignored->{});
|
||||
assertEquals(LocalEventLog.Delivery.EXPORTED,log.ready().toCompletableFuture().get(5,TimeUnit.SECONDS));
|
||||
var recovered=new FishingEventBridge(log::emit,Clock.systemUTC(),data,
|
||||
mock(EquipmentSessions.class),ignored->{});
|
||||
recovered.replay();recovered.close();recovered.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
log.close();
|
||||
var jsonl=new ArrayList<com.google.gson.JsonObject>();
|
||||
try(var files=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
for(var path:files.filter(value->value.toString().endsWith(".jsonl")).toList())
|
||||
for(var line:java.nio.file.Files.readAllLines(path))
|
||||
jsonl.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
var earned=jsonl.stream().filter(value->value.get("type").getAsString()
|
||||
.endsWith("adventure.trophy_earned")).toList();
|
||||
assertEquals(1,earned.size());
|
||||
var details=earned.getFirst().getAsJsonObject("data");
|
||||
assertEquals(receiptId.toString(),details.get("receipt_id").getAsString());
|
||||
assertEquals(2,details.getAsJsonArray("accepted_roster").size());
|
||||
assertEquals(2,details.getAsJsonArray("beneficiaries").size());
|
||||
assertEquals(1,details.getAsJsonArray("beneficiaries").asList().stream()
|
||||
.filter(value->value.getAsJsonObject().get("allocation_status").getAsString()
|
||||
.equals("zero_share_completion")).count());
|
||||
}
|
||||
|
||||
@Test void largeChampionRosterAndFrozenBeneficiariesKeepSeparateLinkedProof() 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 roster=new ArrayList<String>();var beneficiaries=new ArrayList<Map<String,String>>();
|
||||
for(int index=0;index<600;index++){
|
||||
var id=UUID.randomUUID().toString();roster.add(id);
|
||||
if(index<90)beneficiaries.add(Map.of("character_id",id,"allocated_xp","0",
|
||||
"allocated_coins","0","allocation_status","zero_share_completion"));
|
||||
}
|
||||
var dataMap=new LinkedHashMap<String,Object>();dataMap.put("accepted_roster",roster);
|
||||
dataMap.put("beneficiaries",beneficiaries);dataMap.put("receipt_id",UUID.randomUUID().toString());
|
||||
var receipt=UUID.randomUUID();bridge.durable("adventure.trophy_earned",receipt,
|
||||
new FishingEventBridge.Actor("system",null),dataMap);
|
||||
bridge.close();bridge.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
assertTrue(failures.isEmpty(),failures.toString());
|
||||
assertEquals(35,published.size());
|
||||
assertEquals(600,published.stream().mapToInt(event->((List<?>)event.data()
|
||||
.get("accepted_roster")).size()).sum());
|
||||
assertEquals(90,published.stream().mapToInt(event->((List<?>)event.data()
|
||||
.get("beneficiaries")).size()).sum());
|
||||
assertEquals(35,published.stream().map(event->event.id()).distinct().count());
|
||||
assertTrue(published.stream().allMatch(event->event.actionId().equals(receipt)
|
||||
&&event.data().get("part_count").equals(35)));
|
||||
}
|
||||
|
||||
@Test void unavailablePlayerWorldDoesNotEraseAnAuthorizedConfigRead() throws Exception {
|
||||
var published=new ArrayList<EventInput>();
|
||||
var sessions=mock(EquipmentSessions.class);
|
||||
|
||||
@@ -450,7 +450,7 @@ final class FishingQuestNativeTest {
|
||||
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
|
||||
assertEquals(Set.of("fishing","champion_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()));
|
||||
|
||||
@@ -34,6 +34,8 @@ final class ChampionTrophyDirectoryTest {
|
||||
assertEquals(FishingDirectory.TrophyRedeemStatus.APPLIED,directory.redeemTrophy(trophyId,carrier).get(5,TimeUnit.SECONDS));
|
||||
assertTrue(directory.trophies(carrier).isEmpty());
|
||||
assertEquals(new FishingState.Reward(BigInteger.valueOf(150),BigInteger.valueOf(40)),directory.unclaimed(beneficiary));
|
||||
assertTrue(Files.readString(data.resolve("fishing.yml")).contains("source-kind: champion_trophy"),
|
||||
"source type must survive consumption of the last native trophy item");
|
||||
assertEquals(FishingDirectory.TrophyRedeemStatus.REDEEMED,directory.redeemTrophy(trophyId,carrier).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(FishingDirectory.TrophyPrepareStatus.REDEEMED,directory.prepareTrophy(trophy).get(5,TimeUnit.SECONDS));
|
||||
}
|
||||
@@ -44,6 +46,55 @@ final class ChampionTrophyDirectoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void redemptionCapturesAllocationIdsBeforeAConcurrentClaimRemovesStock() throws Exception {
|
||||
UUID carrier=UUID.randomUUID(),beneficiary=UUID.randomUUID(),zero=UUID.randomUUID();
|
||||
var trophy=new FishingState.Trophy(UUID.randomUUID(),FishingState.TrophyType.CHAMPION,
|
||||
carrier,UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),
|
||||
"Daybreak Champion Head","Peak",java.util.Map.of(beneficiary,
|
||||
new FishingState.Reward(BigInteger.ONE,BigInteger.ZERO),zero,FishingState.Reward.empty()));
|
||||
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
assertEquals(FishingDirectory.TrophyPrepareStatus.APPLIED,
|
||||
directory.prepareTrophy(trophy).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(FishingDirectory.TrophyCommitStatus.APPLIED,
|
||||
directory.commitTrophy(trophy.id()).get(5,TimeUnit.SECONDS));
|
||||
var receipt=directory.redeemTrophyWithEvidence(trophy.id(),carrier).get(5,TimeUnit.SECONDS);
|
||||
assertEquals(FishingDirectory.TrophyRedeemStatus.APPLIED,receipt.status());
|
||||
assertEquals(1,receipt.allocations().size());
|
||||
var allocation=receipt.allocations().getFirst();
|
||||
assertEquals(beneficiary,allocation.character());
|
||||
assertEquals("champion_trophy",allocation.sourceKind());
|
||||
var claim=directory.beginClaim(beneficiary).get(5,TimeUnit.SECONDS).orElseThrow();
|
||||
assertTrue(directory.finishClaim(claim.id()).get(5,TimeUnit.SECONDS));
|
||||
assertTrue(directory.snapshot().allocations().isEmpty());
|
||||
assertEquals(allocation,receipt.allocations().getFirst(),
|
||||
"an asynchronous event callback must use the immutable redemption result");
|
||||
assertEquals(FishingDirectory.TrophyRedeemStatus.REDEEMED,
|
||||
directory.redeemTrophyWithEvidence(trophy.id(),carrier).get(5,TimeUnit.SECONDS).status());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void capturedChampionIdentityAndLocationSurvivePreparedRecoveryAndCommit() throws Exception {
|
||||
UUID carrier=UUID.randomUUID(),champion=UUID.randomUUID();
|
||||
var audit=java.util.Map.of("champion_id",champion.toString(),"defeat_world_id",UUID.randomUUID().toString(),
|
||||
"defeat_x","12.5","defeat_y","64.0","defeat_z","-4.0",
|
||||
"unit_kind","party","accepted_roster",carrier.toString());
|
||||
var trophy=new FishingState.Trophy(UUID.randomUUID(),FishingState.TrophyType.CHAMPION,carrier,
|
||||
UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),
|
||||
"Daybreak Champion Head","Summit",
|
||||
java.util.Map.of(carrier,new FishingState.Reward(BigInteger.ONE,BigInteger.ZERO)),
|
||||
FishingState.TrophyPhase.PREPARED,audit);
|
||||
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
assertEquals(FishingDirectory.TrophyPrepareStatus.APPLIED,directory.prepareTrophy(trophy).get(5,TimeUnit.SECONDS));
|
||||
}
|
||||
try(var restarted=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
assertEquals(audit,restarted.snapshot().trophies().getFirst().audit());
|
||||
assertEquals(FishingDirectory.TrophyCommitStatus.APPLIED,
|
||||
restarted.commitTrophy(trophy.id()).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(audit,restarted.trophies(carrier).getFirst().audit());
|
||||
assertEquals(2,restarted.trophies(carrier).getFirst().revision());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void releasedFishingSchemaLoadsWithEmptyTrophyCollections() throws Exception {
|
||||
Files.writeString(data.resolve("fishing.yml"),"schema-version: 1\ninventory: []\ncatches: []\ncatch-receipts: []\nsubmissions: []\ncompletions: []\nallocations: []\nclaims: []\n");
|
||||
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){assertTrue(directory.snapshot().trophies().isEmpty());assertTrue(directory.snapshot().trophyReceipts().isEmpty());}
|
||||
|
||||
Reference in new issue
Block a user