Compare commits

...
5 Commits
Author SHA1 Message Date
dmg f8fed8998b fix(rewards): apply mob spacing to coins
Release / release (push) Successful in 11m12s
2026-09-23 08:35:39 -04:00
dmg 9837afc549 fix(sessions): preserve cross-world Tail delivery
Release / release (push) Successful in 11m37s
2026-09-23 08:22:11 -04:00
dmg 1ea4e8c13f feat(particles): add account-wide visibility control
Release / release (push) Successful in 11m33s
2026-09-23 07:11:10 -04:00
dmg e3f2f2481b feat(particles): add viewer-specific active auras
Release / release (push) Successful in 11m38s
2026-09-23 06:28:58 -04:00
dmg 6ec97569d3 feat(admin): add read-only active character view
Release / release (push) Successful in 11m51s
2026-09-23 00:02:07 -04:00
44 changed files with 1922 additions and 166 deletions

No files matched your search

+2 -1
View File
@@ -136,6 +136,7 @@ Optional `character-swap-cooldown-seconds` defaults to `900`; zero bypasses the
Administration (permission `fantasystick.admin`, default operators):
- `/fantasystick character view <online-player>` — opens the exact online account's current active character as a complete read-only virtual book for an in-game administrator.
- `/fantasystick inspect <player-uuid>` — asynchronous receipt/native-session inspection.
- `/fantasystick recover <player-uuid>` — validated online equipment/game-mode restoration only; no forced overwrite, progression grant or ambiguous replay.
- `/fantasystick config adventure-mode [true|false]` — show or persist the policy for new sessions; no change to active sessions.
@@ -166,7 +167,7 @@ A same-process retry can acknowledge gear already verified restored for that exa
## Native fantasy health, exit and defeat
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and shows public blue particles. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and supplies the recurring blue active-character aura to native viewers who opt in with `/fantasyparticles players on`. The account-owned aura preference defaults to off; `/fantasyparticles players` reports it and `/fantasyparticles players off` hides only this recurring aura. The independent, default-on `/fantasyparticles all [on|off]` preference durably controls every explicit Fantasy Stick particle for that account without changing nearby players' delivery, sounds or gameplay. Turning `all` off temporarily takes precedence without overwriting the saved `players` choice. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
`NativeMaximumHealth` owns only the effective-value cache/dirty callback of that player's existing native maximum-health instance. Base values, modifiers and native potion expiry remain owned by their original sources; nothing is copied back from an old modifier snapshot. The native player health methods and client attribute snapshot are tested. This adapter uses **pinned Purpur 2618 internals**, including private fields; a detached startup probe exercises installation, recalculation and removal before gameplay is registered. Runtime upgrades require renewed native compatibility verification, not a guessed fallback or global attribute-limit changes. Matching native health scaling displays the real fantasy maximum, not a separate virtual health pool; a later unrelated display-scale change is preserved on exit.
@@ -55,6 +55,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private EquipmentAdminCommand admin;
private games.dmg.fantasystick.persistence.PlayerFeedbackPreferences feedbackPreferences;
private games.dmg.fantasystick.session.FantasyFeedbackCommand feedbackCommand;
private games.dmg.fantasystick.session.FantasyParticlesCommand particlesCommand;
private games.dmg.fantasystick.presentation.ParticleDelivery particleDelivery;
private games.dmg.fantasystick.presentation.CombatFeedback combatFeedback;
private volatile Object lifetime;
private CharacterPresentation presentation;
@@ -272,8 +274,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
} catch (java.io.IOException invalidPreferences) {
throw new java.io.UncheckedIOException("Player feedback preferences could not be loaded", invalidPreferences);
}
particleDelivery = new games.dmg.fantasystick.presentation.ParticleDelivery(getServer(), feedbackPreferences,
failure -> getLogger().log(Level.WARNING, "Player particle delivery failed", failure));
feedbackCommand = new games.dmg.fantasystick.session.FantasyFeedbackCommand(feedbackPreferences, nextTick,
failure -> getLogger().log(Level.WARNING, "Player feedback preference failed", failure));
particlesCommand = new games.dmg.fantasystick.session.FantasyParticlesCommand(feedbackPreferences, nextTick,
failure -> getLogger().log(Level.WARNING, "Player particle preference failed", failure));
java.util.function.Consumer<Throwable> presentationFailures = problem ->
getLogger().log(Level.WARNING, "Character presentation failed", problem);
// This supplier belongs to one enable, never the mutable plugin field after re-enable.
@@ -356,15 +362,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatExperience = new games.dmg.fantasystick.party.CombatExperience(characters, rules, rewardRules,
sessions, parties, coinSettings::rules, java.util.random.RandomGenerator.getDefault(),
Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()));
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()),
particleDelivery);
sessions.attachPvpDefeat(combatExperience::consumePvpDefeat);
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
fireballSettings = new games.dmg.fantasystick.persistence.FireballSettings(
getDataFolder().toPath(), fireballConfiguration);
fireball = new games.dmg.fantasystick.ability.Fireball(fireballInitial);
fireball = new games.dmg.fantasystick.ability.Fireball(fireballInitial, particleDelivery);
frostShardSettings = new games.dmg.fantasystick.persistence.FrostShardSettings(
getDataFolder().toPath(), frostShardConfiguration);
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial);
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial, particleDelivery);
longbowSettings = new games.dmg.fantasystick.persistence.LongbowSettings(
getDataFolder().toPath(), longbowConfiguration);
longbow = new games.dmg.fantasystick.ability.Longbow(longbowInitial);
@@ -414,21 +421,24 @@ public final class FantasyStickPlugin extends JavaPlugin {
rallyingCryInitial, sessions, parties, outgoingDamageBonus);
frostNovaSettings = new games.dmg.fantasystick.persistence.FrostNovaSettings(
getDataFolder().toPath(), frostNovaConfiguration);
frostNova = new games.dmg.fantasystick.ability.FrostNova(frostNovaInitial, sessions, parties);
frostNova = new games.dmg.fantasystick.ability.FrostNova(frostNovaInitial, sessions, parties, particleDelivery);
chainLightningSettings = new games.dmg.fantasystick.persistence.ChainLightningSettings(
getDataFolder().toPath(), chainLightningConfiguration);
chainLightning = new games.dmg.fantasystick.ability.ChainLightning(chainLightningInitial, sessions, parties);
chainLightning = new games.dmg.fantasystick.ability.ChainLightning(
chainLightningInitial, sessions, parties, particleDelivery);
sanctuarySettings = new games.dmg.fantasystick.persistence.SanctuarySettings(
getDataFolder().toPath(), sanctuaryConfiguration);
sanctuary = new games.dmg.fantasystick.ability.Sanctuary(sanctuaryInitial, characters, sessions, parties,
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()),
particleDelivery);
warBannerSettings = new games.dmg.fantasystick.persistence.WarBannerSettings(
getDataFolder().toPath(), warBannerConfiguration);
warBanner = new games.dmg.fantasystick.ability.WarBanner(warBannerInitial, sessions, parties, outgoingDamageBonus);
warBanner = new games.dmg.fantasystick.ability.WarBanner(
warBannerInitial, sessions, parties, outgoingDamageBonus, particleDelivery);
meteorSettings = new games.dmg.fantasystick.persistence.MeteorSettings(getDataFolder().toPath(), meteorConfiguration);
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties);
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties, particleDelivery);
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
@@ -546,11 +556,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
armorerController=new games.dmg.fantasystick.shop.FantasyArmorerController(this,armorers,armorShopMenus::open);
adventureQuestController = new games.dmg.fantasystick.adventure.AdventureQuestController(this,
adventureQuests, adventureQuestSettings, sessions, parties, characters, rules, nextTick,
Clock.systemUTC(), java.util.random.RandomGenerator.getDefault());
Clock.systemUTC(), java.util.random.RandomGenerator.getDefault(), particleDelivery);
adventureQuestCommands = new games.dmg.fantasystick.adventure.AdventureQuestCommands(
this, adventureQuests, adventureQuestController, nextTick);
var bossRecoverySettings=outOfCombatRecoverySettings;var bossQuickerRegeneration=quickerRegeneration;var bossQuickerRegenerationII=quickerRegenerationII;var bossHealthStealSettings=healthStealSettings;
dungeonController = new games.dmg.fantasystick.dungeon.DungeonController(this,dungeons,adventureQuests,sessions,parties,characters,rules,nextTick,java.util.random.RandomGenerator.getDefault(),abilityRegistry,passiveRegistry,bossRecoverySettings::settings,record->{if(!record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID))return java.math.BigInteger.ZERO;var bonus=bossQuickerRegeneration.bonus();return record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationII.ID)?bonus.add(bossQuickerRegenerationII.bonus()):bonus;},()->bossHealthStealSettings.settings().healing());
dungeonController = new games.dmg.fantasystick.dungeon.DungeonController(this,dungeons,adventureQuests,sessions,parties,characters,rules,nextTick,java.util.random.RandomGenerator.getDefault(),abilityRegistry,passiveRegistry,bossRecoverySettings::settings,record->{if(!record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID))return java.math.BigInteger.ZERO;var bonus=bossQuickerRegeneration.bonus();return record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationII.ID)?bonus.add(bossQuickerRegenerationII.bonus()):bonus;},()->bossHealthStealSettings.settings().healing(),particleDelivery);
var custodyDirectory=dungeons;
sessions.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
characters.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
@@ -585,7 +595,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
characters, rules, sessions, nextTick, getServer()::getPlayer,
problem -> getLogger().log(Level.WARNING,
"Admin experience grant for character " + problem.character(), problem.cause())));
"Admin experience grant for character " + problem.character(), problem.cause()), particleDelivery));
admin.attachSwitchDelay(switchDelaySettings);
admin.attachRoleplaySettings(roleplaySettings);
admin.attachPartySettings(partySettings);
@@ -608,6 +618,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
"Admin character issue " + problem.operation() + " for player " + problem.player()
+ "; character " + problem.character(), problem.cause())));
admin.attachCharacterBooks(books);
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
@@ -615,6 +626,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
"Missing packaged fantasyfeedback command");
playerFeedbackCommand.setExecutor(feedbackCommand);
playerFeedbackCommand.setTabCompleter(feedbackCommand);
var playerParticlesCommand = java.util.Objects.requireNonNull(getCommand("fantasyparticles"),
"Missing packaged fantasyparticles command");
playerParticlesCommand.setExecutor(particlesCommand);
playerParticlesCommand.setTabCompleter(particlesCommand);
getServer().getPluginManager().registerEvents(crafting, this);
getServer().getPluginManager().registerEvents(guard, this);
getServer().getPluginManager().registerEvents(anvilNames, this);
@@ -671,7 +686,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(books, this);
getServer().getPluginManager().registerEvents(gameModes, this);
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions, particleDelivery,
failure -> getLogger().log(Level.WARNING, "Fantasy session particle delivery failed", failure));
sessionVisuals.install(this);
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
@@ -697,7 +712,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingChat = chatNames; chatNames = null;
var closingAdmin = admin; admin = null;
var closingFeedbackCommand = feedbackCommand; feedbackCommand = null;
var closingParticlesCommand = particlesCommand; particlesCommand = null;
var closingFeedbackPreferences = feedbackPreferences; feedbackPreferences = null;
particleDelivery = null;
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingBooks = books; books = null;
@@ -805,6 +822,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
if (closingFeedbackCommand != null) cleanup("feedback command", closingFeedbackCommand::close);
if (closingParticlesCommand != null) cleanup("particles command", closingParticlesCommand::close);
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
if (closingSettings != null) cleanup("configuration", closingSettings::close);
if (closingFireballSettings != null) cleanup("Fireball configuration", closingFireballSettings::close);
@@ -1266,8 +1284,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
default -> notice.message();
};
player.sendMessage(message);
if (notice.code().equals("DEFEAT")) games.dmg.fantasystick.session.SessionVisuals.burst(player, org.bukkit.Color.RED, 12);
else player.spawnParticle(org.bukkit.Particle.ANGRY_VILLAGER, player.getLocation().add(0, 1, 0), 8, 0.3, 0.4, 0.3, 0);
var particles = particleDelivery;
if (particles == null) return;
if (notice.code().equals("DEFEAT")) particles.world(org.bukkit.Particle.DUST,
player.getLocation().add(0, 1, 0), 12, 0.3, 0.4, 0.3, 0,
new org.bukkit.Particle.DustOptions(org.bukkit.Color.RED, 1));
else particles.viewer(player, org.bukkit.Particle.ANGRY_VILLAGER,
player.getLocation().add(0, 1, 0), 8, 0.3, 0.4, 0.3, 0);
};
if (lifetime != generation) return;
if (getServer().isPrimaryThread()) delivery.run();
@@ -55,12 +55,17 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
private volatile Settings settings;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private Attempt damaging;
private boolean closed;
public ChainLightning(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
this(settings, sessions, parties, games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public ChainLightning(Settings settings, EquipmentSessions sessions, PartyRoster parties,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties);
this.parties = Objects.requireNonNull(parties); this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
@@ -181,10 +186,10 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
}
private static void visual(Player caster, LivingEntity target) {
private void visual(Player caster, LivingEntity target) {
try {
var location = target.getLocation().clone().add(0, target.getHeight() / 2, 0);
caster.getWorld().spawnParticle(Particle.ELECTRIC_SPARK, location, 18, 0.3, 0.5, 0.3, 0.02);
particles.world(Particle.ELECTRIC_SPARK, location, 18, 0.3, 0.5, 0.3, 0.02);
caster.getWorld().playSound(location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 0.35f, 1.7f);
} catch (RuntimeException ignored) { }
}
@@ -44,6 +44,7 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
}
private volatile Settings settings;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Map<UUID, State> projectiles = new HashMap<>();
private State damaging;
private boolean closed;
@@ -68,7 +69,12 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
}
}
public Fireball(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public Fireball(Settings settings) {
this(settings, games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public Fireball(Settings settings, games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
@@ -119,7 +125,7 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), execution::finish);
execution.later(current.lifetime(), execution::finish);
player.getWorld().spawnParticle(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
particles.world(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.MONITOR)
@@ -85,13 +85,18 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
private volatile Settings settings;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Map<AbilityExecution, LeaseGroup> active = new java.util.IdentityHashMap<>();
private Attempt damaging;
private boolean closed;
public FrostNova(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
this(settings, sessions, parties, games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public FrostNova(Settings settings, EquipmentSessions sessions, PartyRoster parties,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties);
this.parties = Objects.requireNonNull(parties); this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
@@ -145,7 +150,7 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
captured.slownessAmplifier(), false, true, true));
if (lease.apply()) group.leases.add(lease);
}
try { source.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 32,
try { particles.world(Particle.SNOWFLAKE, origin, 32,
captured.radius() / 2, captured.radius() / 2, captured.radius() / 2, 0.01); }
catch (RuntimeException ignored) { }
if (group.leases.isEmpty()) execution.finish();
@@ -99,11 +99,17 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
}
private volatile Settings settings;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Map<UUID, State> projectiles = new HashMap<>();
private State damaging;
private boolean closed;
public FrostShard(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public FrostShard(Settings settings) {
this(settings, games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public FrostShard(Settings settings, games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
@@ -161,7 +167,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
execution.later(current.lifetime(), () -> {
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
});
player.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
particles.world(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.HIGHEST)
@@ -48,14 +48,16 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
void step(){if(closed||impacted)return;if(!execution.stillCurrent()){execution.finish();return;}elapsed++;if(elapsed>=maximumTicks){impact();execution.finish();return;}warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{execution.damage(target,amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
boolean groundCurrent(){try{return ground.origin().getWorld()!=null&&ground.block().getWorld()==ground.origin().getWorld()&&ground.block().getType().isSolid();}catch(RuntimeException invalid){return false;}}
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);ground.origin().getWorld().spawnParticle(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
void impactVisual(){try{var world=ground.origin().getWorld();world.spawnParticle(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);world.spawnParticle(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);particles.world(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
void impactVisual(){try{var world=ground.origin().getWorld();particles.world(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);particles.world(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
@Override public void close(){if(closed)return;closed=true;warnings.remove(execution,this);}
}
private volatile Settings settings;private final EquipmentSessions sessions;private final PartyRoster parties;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Map<AbilityExecution,Warning> warnings=new IdentityHashMap<>();private Attempt damaging;private boolean closed;
public Meteor(Settings settings,EquipmentSessions sessions,PartyRoster parties){this.settings=Objects.requireNonNull(settings);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);}
public Meteor(Settings settings,EquipmentSessions sessions,PartyRoster parties){this(settings,sessions,parties,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
public Meteor(Settings settings,EquipmentSessions sessions,PartyRoster parties,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.settings=Objects.requireNonNull(settings);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.particles=Objects.requireNonNull(particles);}
public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("meteor","Meteor",Material.FIRE_CHARGE,
List.of("Mark visible ground within "+decimal(current.targetRange())+" blocks for a "+seconds(current.warning())+"-second warning.","Impact exposed enemies in a "+decimal(current.radius())+"-block area for "+current.baseDamage()+" + INT modifier damage."),
@@ -137,7 +137,7 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
return record == null ? null : new Target(player, active, record);
}
void visual() {
try { source.getWorld().spawnParticle(Particle.END_ROD, origin, 12,
try { particles.world(Particle.END_ROD, origin, 12,
captured.radius() / 2, 0.1, captured.radius() / 2, 0); } catch (RuntimeException ignored) { }
}
void closeMitigation() {
@@ -153,18 +153,28 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
private final CharacterStorage characters; private final EquipmentSessions sessions; private final PartyRoster parties;
private final CombatMitigation combatMitigation; private final Consumer<Runnable> nextTick;
private final Consumer<EntityRegainHealthEvent> healingEvents; private final Supplier<BigDecimal> nativeLimit;
private final Consumer<MendingLight.Support> supports; private final List<State> states = new ArrayList<>();
private final Consumer<MendingLight.Support> supports;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final List<State> states = new ArrayList<>();
private boolean closed;
public Sanctuary(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
CombatMitigation combatMitigation, Consumer<Runnable> nextTick,
Consumer<EntityRegainHealthEvent> healingEvents, Supplier<BigDecimal> nativeLimit,
Consumer<MendingLight.Support> supports) {
this(settings, characters, sessions, parties, combatMitigation, nextTick, healingEvents, nativeLimit, supports,
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public Sanctuary(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
CombatMitigation combatMitigation, Consumer<Runnable> nextTick,
Consumer<EntityRegainHealthEvent> healingEvents, Supplier<BigDecimal> nativeLimit,
Consumer<MendingLight.Support> supports,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
this.combatMitigation = Objects.requireNonNull(combatMitigation); this.nextTick = Objects.requireNonNull(nextTick);
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
this.supports = Objects.requireNonNull(supports);
this.supports = Objects.requireNonNull(supports); this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
@@ -97,7 +97,7 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
var ordered = new LinkedHashMap<UUID, Player>(); for (var player : result) ordered.put(player.getUniqueId(), player); return ordered;
}
void visual() {
try { origin.getWorld().spawnParticle(Particle.DUST, origin.clone().add(0, 1.5, 0), 16,
try { particles.world(Particle.DUST, origin.clone().add(0, 1.5, 0), 16,
0.25, 1.5, 0.25, 0, new Particle.DustOptions(Color.RED, 1.5f)); }
catch (RuntimeException ignored) { }
}
@@ -157,12 +157,19 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
private volatile Settings settings;
private final EquipmentSessions sessions; private final PartyRoster parties; private final OutgoingDamageBonus bonuses;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Map<Long, Area> areas = new java.util.HashMap<>(); private final Map<UUID, ResistanceState> resistances = new java.util.HashMap<>();
private long sequences; private long resistanceSequences; private org.bukkit.scheduler.BukkitTask sweepTask; private boolean closed;
public WarBanner(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses) {
this(settings, sessions, parties, bonuses,
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public WarBanner(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties); this.bonuses = Objects.requireNonNull(bonuses);
this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void install(org.bukkit.plugin.Plugin plugin) {
@@ -31,6 +31,7 @@ import org.bukkit.plugin.Plugin;
public final class AdventureQuestController implements Listener,AutoCloseable {
private static final String GIVER="quest_giver",ENEMY="adventure_enemy",PROJECTILE="adventure_projectile";
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestSettings settings;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final AdventureQuestNavigation navigation;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;
private final Consumer<Runnable> nextTick;private final Clock clock;private final java.util.random.RandomGenerator random;
private final NamespacedKey typeKey,idKey,siteKey,catalogKey,menuSiteKey,menuCatalogKey,menuPageKey;
@@ -41,7 +42,8 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
private static final class MenuHolder implements InventoryHolder {final UUID catalog;final int page;MenuHolder(UUID catalog,int page){this.catalog=catalog;this.page=page;}@Override public Inventory getInventory(){return null;}}
private static final class Attempt {final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final AdventureQuestState.Arena arena;final int resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;Attempt(UUID site,UUID catalog,AdventureQuestState.SiteType type,AdventureQuestState.Arena arena,int resetSeconds){this.site=site;this.catalog=catalog;this.type=type;this.arena=arena;this.resetSeconds=resetSeconds;}}
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.settings=Objects.requireNonNull(settings);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");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");menuPageKey=new NamespacedKey(plugin,"quest_offer_page");}
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");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");menuPageKey=new NamespacedKey(plugin,"quest_offer_page");}
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));}
@@ -69,7 +71,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
public void tracking(Player player,Boolean enabled){var context=context(player);if(context==null){player.sendMessage("Quest tracking refused: actively play a living fantasy character first.");return;}var quest=directory.activeFor(context.character,context.partyId).filter(value->value.snapshot().contains(context.character)).orElse(null);if(quest==null){player.sendMessage("Quest tracking refused: this character has no eligible active quest.");refreshTracking(player);return;}if(enabled==null){player.sendMessage("Quest tracking is "+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+" for the current adventure quest.");return;}directory.setTracking(quest.id(),context.character,enabled).whenComplete((status,failure)->schedule(()->{if(failure!=null||status==AdventureQuestDirectory.TrackingStatus.INELIGIBLE){player.sendMessage("Quest tracking change failed; retained state is unchanged.");return;}refreshTracking(player);player.sendMessage("Quest tracking is now "+(enabled?"on.":"off."));}));}
public void inspect(Player player){var context=context(player);if(context==null){player.sendMessage("No active fantasy character.");return;}var quest=directory.activeFor(context.character,context.partyId).orElse(null);if(quest==null){player.sendMessage("No active adventure quest.");return;}var site=directory.findSite(quest.site()).orElse(null);if(site==null){player.sendMessage("Active quest site is unavailable.");return;}player.sendMessage(site.displayName()+"; state="+(attempts.containsKey(site.id())?"active":"waiting")+"; expires "+quest.expiresAt()+"; Quest Compass guidance="+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+".");}
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt);attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt);attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){if(directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null)==AdventureQuestState.SiteType.DUNGEON)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
private void showArenaBoundaries(){for(UUID siteId:directory.active().stream().map(AdventureQuestState.Quest::site).distinct().toList()){var site=directory.findSite(siteId).orElse(null);if(site==null||site.type()==AdventureQuestState.SiteType.DUNGEON)continue;var world=plugin.getServer().getWorld(site.world());if(world==null)continue;var center=new Location(world,site.x(),site.y(),site.z());var points=perimeter(arena(site));for(var player:plugin.getServer().getOnlinePlayers()){if(eligible(player,siteId)==null)continue;var viewer=player.getLocation();if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world())||viewer.distanceSquared(center)>64d*64d)continue;for(var point:points)player.spawnParticle(Particle.END_ROD,new Location(world,site.x()+point[0],site.y()+0.2,site.z()+point[1]),1,0,0,0,0);}}}
private void showArenaBoundaries(){for(UUID siteId:directory.active().stream().map(AdventureQuestState.Quest::site).distinct().toList()){var site=directory.findSite(siteId).orElse(null);if(site==null||site.type()==AdventureQuestState.SiteType.DUNGEON)continue;var world=plugin.getServer().getWorld(site.world());if(world==null)continue;var center=new Location(world,site.x(),site.y(),site.z());var points=perimeter(arena(site));for(var player:plugin.getServer().getOnlinePlayers()){if(eligible(player,siteId)==null)continue;var viewer=player.getLocation();if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world())||viewer.distanceSquared(center)>64d*64d)continue;for(var point:points)particles.viewer(player,Particle.END_ROD,new Location(world,site.x()+point[0],site.y()+0.2,site.z()+point[1]),1,0,0,0,0);}}}
private static List<double[]> perimeter(AdventureQuestState.Arena arena){var result=new ArrayList<double[]>();if(arena.shape()==AdventureQuestState.ArenaShape.CIRCLE){int count=Math.min(256,Math.max(16,(int)Math.ceil(Math.PI*arena.xBlocks())));for(int index=0;index<count;index++){double angle=2*Math.PI*index/count;result.add(new double[]{Math.cos(angle)*arena.xBlocks(),Math.sin(angle)*arena.xBlocks()});}}else{double halfX=arena.xBlocks()/2d,halfZ=arena.zBlocks()/2d;edge(result,-halfX,halfX,-halfZ,true);edge(result,-halfX,halfX,halfZ,true);edge(result,-halfZ,halfZ,-halfX,false);edge(result,-halfZ,halfZ,halfX,false);}return result.size()<=256?List.copyOf(result):List.copyOf(result.subList(0,256));}
private static void edge(List<double[]> result,double from,double to,double fixed,boolean xAxis){for(double value=from;value<to;value+=2)result.add(xAxis?new double[]{value,fixed}:new double[]{fixed,value});result.add(xAxis?new double[]{to,fixed}:new double[]{fixed,to});}
private void contain(Attempt attempt){var site=directory.findSite(attempt.site).orElse(null);if(site==null)return;var world=plugin.getServer().getWorld(site.world());if(world==null)return;for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity))&&!inside(entity.getLocation(),site,attempt.arena)){relocating.add(entity);try{entity.teleport(new Location(world,site.x(),site.y(),site.z()));if(entity instanceof LivingEntity living)living.setVelocity(new org.bukkit.util.Vector());}finally{relocating.remove(entity);}}}
@@ -29,10 +29,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
private final games.dmg.fantasystick.passive.PassiveRegistry passives;
private final games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
private final org.bukkit.Server server;
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private final Map<UUID, Object> adminPending = new ConcurrentHashMap<>();
private final Map<UUID, AdminView> adminViews = new ConcurrentHashMap<>();
private volatile boolean closed;
private static final int VOLUME_SIZE = 30; // Reserve room for linked ability details, navigation and party pages.
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
private static final net.kyori.adventure.key.Key ADMIN_PAGES = net.kyori.adventure.key.Key.key("fantasystick", "admin_character_pages");
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
private static final net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
private static final net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
@@ -42,6 +46,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})\"");
private record View(String nonce, ControlInput input, int previous, int next) { }
private record AdminView(String nonce, UUID administrator, org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active,
int previous, int next) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
@@ -87,11 +94,76 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
this.abilityEngine = abilityEngine;
this.passives = Objects.requireNonNull(passives);
this.passiveEngine = passiveEngine;
this.server = Objects.requireNonNull(server);
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
}
@Override public void accept(ControlInput input) { load(input, 0); }
public void openReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target) {
Objects.requireNonNull(administrator); Objects.requireNonNull(target);
if (closed) return;
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission is required.");
return;
}
UUID targetId = target.getUniqueId();
var active = sessions.active(targetId).filter(sessions::current).orElse(null);
if (active == null) {
administrator.sendMessage("Character view refused: the target has no current active character.");
return;
}
loadReadOnly(administrator, target, active, 0);
}
private void loadReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active, int offset) {
if (closed) return;
UUID administratorId = administrator.getUniqueId();
UUID targetId = target.getUniqueId();
Object ticket = new Object();
adminViews.remove(administratorId);
adminPending.put(administratorId, ticket);
characters.find(active.character()).whenComplete((record, failure) -> {
if (closed || adminPending.get(administratorId) != ticket) return;
try {
nextTick.accept(() -> {
if (closed || !adminPending.remove(administratorId, ticket)) return;
var currentTarget = server.getPlayer(targetId);
var current = currentTarget == null ? null : sessions.active(targetId)
.filter(sessions::current).orElse(null);
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission changed.");
return;
}
if (currentTarget != target || !target.isOnline() || !active.equals(current)) {
administrator.sendMessage("Character view refused: the target character session changed.");
return;
}
if (failure != null) {
report(new Failure(administratorId, active.character(), failure));
administrator.sendMessage("Character view could not be loaded; no state was changed.");
return;
}
if (record.isEmpty() || record.orElseThrow().dead()) {
administrator.sendMessage("Character view refused: the target character is unavailable.");
return;
}
try { administrator.openBook(renderReadOnly(record.orElseThrow(), administrator,
currentTarget, active, offset)); }
catch (RuntimeException error) {
adminViews.remove(administratorId);
report(new Failure(administratorId, active.character(), error));
administrator.sendMessage("Character view could not be opened; no state was changed.");
}
});
} catch (RuntimeException rejected) {
adminPending.remove(administratorId, ticket);
report(new Failure(administratorId, active.character(), rejected));
}
});
}
private void load(ControlInput input, int offset) {
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|| !input.stillCurrent()) return;
@@ -128,18 +200,33 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
return renderBook(record, input.player(), input, offset, null, null);
}
private ItemStack renderReadOnly(CharacterRecord record, org.bukkit.entity.Player administrator,
org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active,
int offset) {
return renderBook(record, target, null, offset, administrator, active);
}
private ItemStack renderBook(CharacterRecord record, org.bukkit.entity.Player subject,
ControlInput input, int offset, org.bukkit.entity.Player administrator,
games.dmg.fantasystick.session.EquipmentSessions.Active adminActive) {
boolean interactive = input != null;
var item = new ItemStack(Material.WRITTEN_BOOK);
var meta = (BookMeta) item.getItemMeta();
meta.setTitle("Character");
meta.setAuthor("Fantasy Stick");
var statView = new StatView(UUID.randomUUID().toString(), input, record.revision());
var allOverview = overviewPages(record, input, statView);
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision()) : null;
var allOverview = overviewPages(record, subject, statView);
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
var pages = new java.util.ArrayList<Component>(allOverview.subList(start, end));
if(end==allOverview.size())pages.add(gearPage(record));
if (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
if (interactive && record.unspentPoints().signum() > 0 && start == 0)
statViews.put(input.session().player(), statView);
var definitions = abilities.definitions().stream()
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::category)
@@ -151,14 +238,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
.thenComparing(games.dmg.fantasystick.passive.PassiveDefinition::id))
.toList();
if ((!definitions.isEmpty() || !passiveDefinitions.isEmpty()) && end == allOverview.size()) {
var abilityView = new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
var abilityView = interactive ? new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
if (!definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
var passiveView = new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
if (interactive && !definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
var passiveView = interactive ? new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
if (!passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
if (interactive && !passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id,
games.dmg.fantasystick.passive.PassiveDefinition::name));
@@ -208,15 +295,29 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
passiveDetails.forEach(pages::addAll);
}
if (allOverview.size() > VOLUME_SIZE) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < allOverview.size() ? end : -1);
views.put(input.session().player(), view);
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + allOverview.size());
if (view.previous() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
if (interactive) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < allOverview.size() ? end : -1);
views.put(input.session().player(), view);
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
.append(pageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline())
.append(pageButton("Next pages", view, 1));
} else {
var view = new AdminView(UUID.randomUUID().toString(), administrator.getUniqueId(),
subject, adminActive, start > 0 ? start - VOLUME_SIZE : -1,
end < allOverview.size() ? end : -1);
adminViews.put(administrator.getUniqueId(), view);
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
.append(adminPageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline())
.append(adminPageButton("Next pages", view, 1));
}
pages.add(navigation);
}
if (end == allOverview.size()) pages.addAll(partyControls.pages(input));
if (end == allOverview.size()) pages.addAll(interactive
? partyControls.pages(input) : partyControls.readOnlyPages(subject.getUniqueId()));
meta.pages(pages);
item.setItemMeta(meta);
return item;
@@ -225,12 +326,13 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private Component gearPage(CharacterRecord record){return Component.text("Gear\n\nHead: Cosmetic\nChestplate: "+tier(record.gear().chestplate())+"\nLeggings: "+tier(record.gear().leggings())+"\nBoots: "+tier(record.gear().boots()));}
private static String tier(games.dmg.fantasystick.persistence.GearTier tier){String value=tier.name().toLowerCase(java.util.Locale.ROOT);return Character.toUpperCase(value.charAt(0))+value.substring(1);}
private java.util.List<Component> overviewPages(CharacterRecord record, ControlInput input, StatView view) {
private java.util.List<Component> overviewPages(CharacterRecord record, org.bukkit.entity.Player subject,
StatView view) {
var builder = new PageBuilder();
builder.text(record.name());
builder.blank();
for (var stat : Stat.values()) {
Component action = record.unspentPoints().signum() > 0
Component action = view != null && record.unspentPoints().signum() > 0
? statButton(stat, view) : null;
builder.inline(stat + ": " + record.stats().get(stat), action);
}
@@ -241,7 +343,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
builder.text("XP: " + xp);
builder.text("Unspent points: " + record.unspentPoints());
builder.text("Coins: " + record.coins());
builder.text("Health: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
builder.text("Health: " + java.math.BigDecimal.valueOf(subject.getHealth()).min(maximum).stripTrailingZeros().toPlainString()
+ "/" + maximum.stripTrailingZeros().toPlainString());
return builder.pages();
}
@@ -362,7 +464,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
String state = learned ? "Learned" : !prerequisites || !stats ? "Unavailable: requirements"
: !points ? "Unavailable: points" : !slot ? "Unavailable: passive slots" : "Available";
var builder = new PageBuilder(); builder.text(definition.name()); builder.text(requirements);
if (learned) builder.component(passiveButton("Unlearn", PASSIVE_UNLEARN, view, definition.id()));
if (view == null) builder.text(state);
else if (learned) builder.component(passiveButton("Unlearn", PASSIVE_UNLEARN, view, definition.id()));
else if (prerequisites && stats && points && slot) builder.component(passiveButton("Learn", PASSIVE_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank(); definition.description().forEach(builder::text); builder.blank(); builder.text(state);
@@ -387,7 +490,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
builder.text(definition.name());
builder.text(definition.category().name());
builder.text(requirements);
if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
if (view == null) builder.text(state);
else if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
else if (stats && points && slot) builder.component(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank();
@@ -475,6 +579,28 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
}
private static Component adminPageButton(String label, AdminView view, int direction) {
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(ADMIN_PAGES,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
}
@org.bukkit.event.EventHandler
public void adminPageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !ADMIN_PAGES.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var administrator = connection.getPlayer();
var view = adminViews.get(administrator.getUniqueId());
if (view == null || event.getTag() == null || !view.administrator().equals(administrator.getUniqueId())) return;
String raw = event.getTag().string();
if (raw.length() > 64) return;
var match = PAGE_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
int offset = match.group(2).equals("0") ? view.previous() : view.next();
if (offset < 0 || !adminViews.remove(administrator.getUniqueId(), view)) return;
loadReadOnly(administrator, view.target(), view.active(), offset);
}
@org.bukkit.event.EventHandler
public void pageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !PAGES.equals(event.getIdentifier())
@@ -493,7 +619,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
private void report(ControlInput input, Throwable failure) {
try { failures.accept(new Failure(input.session().player(), input.session().character(), failure)); }
report(new Failure(input.session().player(), input.session().character(), failure));
}
private void report(Failure failure) {
try { failures.accept(failure); }
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
}
@@ -652,6 +782,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
var owner = event.getPlayer().getUniqueId();
pending.remove(owner);
adminPending.remove(owner);
adminViews.remove(owner);
views.remove(owner);
statViews.remove(owner);
abilityViews.remove(owner);
@@ -660,6 +792,6 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
@Override public void close() {
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
closed = true; pending.clear(); adminPending.clear(); adminViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
}
}
@@ -54,6 +54,12 @@ public final class PartyBookControls implements AutoCloseable {
public List<Component> pages(ControlInput input) { return render(input, Menu.ROOT, 0); }
/** Informational party pages with no registered action or retained viewer state. */
public List<Component> readOnlyPages(UUID account) {
if (closed) return List.of();
return rootPages(Objects.requireNonNull(account));
}
private List<Component> render(ControlInput input, Menu menu, int offset) {
if (closed || !input.stillCurrent()) return List.of();
var build = new Pages(input);
@@ -61,18 +67,7 @@ public final class PartyBookControls implements AutoCloseable {
var party = parties.partyOf(owner);
if (menu == Menu.PLAYERS && (party.isEmpty() || !party.orElseThrow().leader().equals(owner))) menu = Menu.ROOT;
if (menu == Menu.ROOT) {
Component summary = Component.text("Party\n\n");
if (party.isEmpty()) {
build.pages.add(summary.append(Component.text("No party.\n\nVisit a Party\nRegistrar.\nCreate, join, or\nmanage one.")));
} else {
var group = party.orElseThrow();
summary = summary.append(Component.text(group.characters().size() + " members\n" + group.members().size() + " active\n\nManage this party\nonly at a Party\nRegistrar."));
build.pages.add(summary);
for (var member : group.characters()) {
Component label=parties.activePlayer(member).map(names).orElse(Component.text("Character\n"+member.toString().substring(0,8)));
build.pages.add(Component.text(member.equals(group.leaderCharacter()) ? "Leader\n\n" : "Member\n\n").append(label));
}
}
build.pages.addAll(rootPages(owner));
} else if (menu == Menu.PLAYERS) {
var group = party.orElseThrow();
var candidates = server.getOnlinePlayers().stream().filter(player -> player.isOnline())
@@ -108,6 +103,26 @@ public final class PartyBookControls implements AutoCloseable {
return List.copyOf(build.pages);
}
private List<Component> rootPages(UUID owner) {
var pages = new ArrayList<Component>();
var party = parties.partyOf(owner);
Component summary = Component.text("Party\n\n");
if (party.isEmpty()) {
pages.add(summary.append(Component.text("No party.\n\nVisit a Party\nRegistrar.\nCreate, join, or\nmanage one.")));
} else {
var group = party.orElseThrow();
summary = summary.append(Component.text(group.characters().size() + " members\n" + group.members().size()
+ " active\n\nManage this party\nonly at a Party\nRegistrar."));
pages.add(summary);
for (var member : group.characters()) {
Component label = parties.activePlayer(member).map(names)
.orElse(Component.text("Character\n" + member.toString().substring(0, 8)));
pages.add(Component.text(member.equals(group.leaderCharacter()) ? "Leader\n\n" : "Member\n\n").append(label));
}
}
return List.copyOf(pages);
}
private static int start(int requested, int size) {
return Math.min(Math.max(0, requested), Math.max(0, (size - 1) / PAGE_SIZE * PAGE_SIZE));
}
@@ -33,12 +33,13 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
private static final class Attempt {final UUID dungeon,room,generation;final Map<UUID,UUID> entities=new HashMap<>();final Set<UUID> represented=new HashSet<>();final Map<UUID,Double> threat=new HashMap<>();final Map<String,Long> cooldowns=new HashMap<>();final Map<String,Long> effects=new HashMap<>();UUID boss,target;long lastCombat=System.nanoTime();Attempt(UUID dungeon,UUID room,UUID generation){this.dungeon=dungeon;this.room=room;this.generation=generation;}}
private record Context(UUID character,UUID party,Set<UUID> members){}
private record OwnedEffect(UUID dungeon,UUID player,PotionEffectType type,int amplifier,PotionEffect prior){}
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final NamespacedKey typeKey,dungeonKey,roomKey,slotKey,generationKey,bossKey;
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,new AbilityRegistry(),new PassiveRegistry(),games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,abilities,passives,games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing){this.plugin=Objects.requireNonNull(plugin);this.dungeons=Objects.requireNonNull(dungeons);this.adventures=Objects.requireNonNull(adventures);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.random=Objects.requireNonNull(random);this.abilities=Objects.requireNonNull(abilities);this.passives=Objects.requireNonNull(passives);this.recoverySettings=Objects.requireNonNull(recoverySettings);this.recoveryBonus=Objects.requireNonNull(recoveryBonus);this.healthStealHealing=Objects.requireNonNull(healthStealHealing);typeKey=new NamespacedKey(plugin,"managed_npc_type");dungeonKey=new NamespacedKey(plugin,"dungeon_id");roomKey=new NamespacedKey(plugin,"dungeon_room_id");slotKey=new NamespacedKey(plugin,"dungeon_spawn_id");generationKey=new NamespacedKey(plugin,"dungeon_generation_id");bossKey=new NamespacedKey(plugin,"dungeon_boss");}
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,abilities,passives,recoverySettings,recoveryBonus,healthStealHealing,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.plugin=Objects.requireNonNull(plugin);this.dungeons=Objects.requireNonNull(dungeons);this.adventures=Objects.requireNonNull(adventures);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.random=Objects.requireNonNull(random);this.abilities=Objects.requireNonNull(abilities);this.passives=Objects.requireNonNull(passives);this.recoverySettings=Objects.requireNonNull(recoverySettings);this.recoveryBonus=Objects.requireNonNull(recoveryBonus);this.healthStealHealing=Objects.requireNonNull(healthStealHealing);this.particles=Objects.requireNonNull(particles);typeKey=new NamespacedKey(plugin,"managed_npc_type");dungeonKey=new NamespacedKey(plugin,"dungeon_id");roomKey=new NamespacedKey(plugin,"dungeon_room_id");slotKey=new NamespacedKey(plugin,"dungeon_spawn_id");generationKey=new NamespacedKey(plugin,"dungeon_generation_id");bossKey=new NamespacedKey(plugin,"dungeon_boss");}
public void install(){synchronizeEnabled();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();pulse=plugin.getServer().getScheduler().runTaskTimer(plugin,this::tick,1,1);}
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();if(data==null)return false;var type=data.get(typeKey,PersistentDataType.STRING);return ENEMY.equals(type)||PROJECTILE.equals(type);}
private void synchronizeEnabled(){for(var site:adventures.snapshot().sites())if(site.type()==AdventureQuestState.SiteType.DUNGEON)adventures.removeDungeonSite(site.id());}
@@ -142,7 +143,7 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
private static UUID id(Entity entity,NamespacedKey key){try{var value=entity.getPersistentDataContainer().get(key,PersistentDataType.STRING);return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
private static void copy(org.bukkit.persistence.PersistentDataContainer target,NamespacedKey key,Entity source){var value=source.getPersistentDataContainer().get(key,PersistentDataType.STRING);if(value!=null)target.set(key,PersistentDataType.STRING,value);}
private static <T>List<T> safe(Collection<T> values){return values==null?List.of():List.copyOf(values);}
private void showBorders(){if(closed)return;for(var entry:List.copyOf(playerEdits.entrySet())){var player=plugin.getServer().getPlayer(entry.getKey());if(player==null||!player.isOnline()){done(entry.getKey());continue;}var dungeon=dungeons.find(entry.getValue().dungeon()).orElse(null);if(dungeon==null){done(entry.getKey());continue;}int count=0;for(var room:dungeon.rooms()){var bounds=room.bounds();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(dungeon.world()))continue;var dust=new Particle.DustOptions(entry.getValue().colors().computeIfAbsent(room.id(),ignored->randomColor()),1);for(var point:edges(bounds)){var sample=new Location(location.getWorld(),point[0],point[1],point[2]);if(location.distanceSquared(sample)>64d*64d)continue;if(count++>=1024)return;player.spawnParticle(Particle.DUST,sample,1,0,0,0,0,dust);}}}}
private void showBorders(){if(closed)return;for(var entry:List.copyOf(playerEdits.entrySet())){var player=plugin.getServer().getPlayer(entry.getKey());if(player==null||!player.isOnline()){done(entry.getKey());continue;}var dungeon=dungeons.find(entry.getValue().dungeon()).orElse(null);if(dungeon==null){done(entry.getKey());continue;}int count=0;for(var room:dungeon.rooms()){var bounds=room.bounds();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(dungeon.world()))continue;var dust=new Particle.DustOptions(entry.getValue().colors().computeIfAbsent(room.id(),ignored->randomColor()),1);for(var point:edges(bounds)){var sample=new Location(location.getWorld(),point[0],point[1],point[2]);if(location.distanceSquared(sample)>64d*64d)continue;if(count++>=1024)return;particles.viewer(player,Particle.DUST,sample,1,0,0,0,0,dust);}}}}
static List<double[]> edges(DungeonState.Bounds b){var result=new ArrayList<double[]>();double x1=b.minX(),x2=b.maxX()+1d,y1=b.minY(),y2=b.maxY()+1d,z1=b.minZ(),z2=b.maxZ()+1d;line(result,x1,x2,y1,z1,0);line(result,x1,x2,y1,z2,0);line(result,x1,x2,y2,z1,0);line(result,x1,x2,y2,z2,0);line(result,z1,z2,y1,x1,1);line(result,z1,z2,y1,x2,1);line(result,z1,z2,y2,x1,1);line(result,z1,z2,y2,x2,1);line(result,y1,y2,x1,z1,2);line(result,y1,y2,x1,z2,2);line(result,y1,y2,x2,z1,2);line(result,y1,y2,x2,z2,2);return List.copyOf(result);}
private static void line(List<double[]> out,double from,double to,double fixedA,double fixedB,int axis){for(double value=from;value<to;value+=2)out.add(axis==0?new double[]{value,fixedA,fixedB}:axis==1?new double[]{fixedB,fixedA,value}:new double[]{fixedA,value,fixedB});out.add(axis==0?new double[]{to,fixedA,fixedB}:axis==1?new double[]{fixedB,fixedA,to}:new double[]{fixedA,to,fixedB});}
private static Color randomColor(){var random=java.util.concurrent.ThreadLocalRandom.current();return Color.fromRGB(64+random.nextInt(192),64+random.nextInt(192),64+random.nextInt(192));}
@@ -6,7 +6,6 @@ import games.dmg.fantasystick.progression.CombatContributions;
import games.dmg.fantasystick.progression.CombatRewardRules;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.session.EquipmentSessions;
import games.dmg.fantasystick.session.SessionVisuals;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Clock;
@@ -63,6 +62,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
private final Consumer<Runnable> nextTick;
private final Function<UUID, Player> players;
private final Consumer<Failure> failures;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final CombatSources sources;
private final CombatContributions contributions;
private final PartyRoster parties;
@@ -98,7 +98,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
this(characters, progression, rewards, sessions, new PartyRoster(clock, id -> {
var player = players.apply(id); return player != null && player.isOnline();
}), games.dmg.fantasystick.progression.CoinRules::defaults, java.util.random.RandomGenerator.getDefault(),
clock, nextTick, players, failures);
clock, nextTick, players, failures,
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
@@ -106,12 +107,22 @@ public final class CombatExperience implements Listener, AutoCloseable {
java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules,
java.util.random.RandomGenerator random, Clock clock, Consumer<Runnable> nextTick,
Function<UUID, Player> players, Consumer<Failure> failures) {
this(characters, progression, rewards, sessions, parties, coinRules, random, clock, nextTick, players, failures,
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
EquipmentSessions sessions, PartyRoster parties,
java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules,
java.util.random.RandomGenerator random, Clock clock, Consumer<Runnable> nextTick,
Function<UUID, Player> players, Consumer<Failure> failures,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.characters = Objects.requireNonNull(characters); this.progression = Objects.requireNonNull(progression);
this.rewards = Objects.requireNonNull(rewards); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties); this.coinRules = Objects.requireNonNull(coinRules);
this.random = Objects.requireNonNull(random); this.clock = Objects.requireNonNull(clock);
this.nextTick = Objects.requireNonNull(nextTick); this.players = Objects.requireNonNull(players);
this.failures = Objects.requireNonNull(failures);
this.failures = Objects.requireNonNull(failures); this.particles = Objects.requireNonNull(particles);
sources = new CombatSources(sessions); contributions = new CombatContributions(rewards);
}
@@ -313,7 +324,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
for (int gained = 1; gained <= award.levels(); gained++) {
int level = saved.level() - award.levels() + gained;
player.sendMessage("Level " + level + "! Gained 1 character point.");
SessionVisuals.burst(player, Color.BLUE, 12);
particles.world(org.bukkit.Particle.DUST, player.getLocation().add(0, 1, 0), 12,
0.3, 0.4, 0.3, 0, new org.bukkit.Particle.DustOptions(Color.BLUE, 1));
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
}
} else if (award.status() == CharacterStorage.CombatAwardStatus.SPACING_BLOCKED) {
@@ -323,7 +335,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
} else if (award.status() == CharacterStorage.CombatAwardStatus.UNCHANGED) {
player.sendMessage("No fantasy XP: this target's configured reward is zero.");
}
if (operation.coins.signum() > 0 && award.status() != CharacterStorage.CombatAwardStatus.DUPLICATE) {
if (operation.coins.signum() > 0 && award.status() != CharacterStorage.CombatAwardStatus.DUPLICATE
&& award.status() != CharacterStorage.CombatAwardStatus.SPACING_BLOCKED) {
player.sendMessage("Earned " + operation.coins + " character coins.");
}
}
@@ -378,7 +378,7 @@ public final class CharacterStorage implements AutoCloseable {
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
old.loadout(), old.passives(), old.cooldowns(), history, false, old.deathId(), old.diedAt(),
old.coins().add(coinReward), true, receipts,old.pvpCoinEscrow(),old.gear());
old.coins().add(eligible ? coinReward : BigInteger.ZERO), true, receipts,old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new CombatAward(status, Optional.of(saved), gained);
});
@@ -22,22 +22,35 @@ import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable account-owned routine combat/effect feedback preference. */
/** Durable account-owned presentation preferences, serialized through one save-before-publish worker. */
public final class PlayerFeedbackPreferences implements AutoCloseable {
private static final String FILE = "player-preferences.yml";
private static final String PLAYERS = "players";
private static final String KEY = "combat-feedback";
private static final int MAX_BYTES = 1_048_576;
public record Update(UUID account, boolean enabled, long sequence) {
public Update { Objects.requireNonNull(account); }
public enum Preference {
COMBAT_FEEDBACK("combat-feedback", true),
PLAYER_AURAS("player-aura-particles", false),
ALL_PARTICLES("all-particles", true);
private final String key;
private final boolean defaultValue;
Preference(String key, boolean defaultValue) { this.key = key; this.defaultValue = defaultValue; }
}
private record AccountPreference(UUID account, Preference preference) {
private AccountPreference { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
}
public record Update(UUID account, Preference preference, boolean enabled, long sequence) {
public Update { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
}
private final Path directory;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-player-feedback-preferences"));
private final Map<UUID, Boolean> effective = new java.util.HashMap<>();
private final Map<UUID, Long> published = new java.util.HashMap<>();
private final Map<AccountPreference, Boolean> effective = new java.util.HashMap<>();
private final Map<AccountPreference, Long> published = new java.util.HashMap<>();
private long persistedSequence;
private boolean closed;
private int pending;
@@ -48,13 +61,34 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
effective.putAll(loadEnabled());
}
public synchronized boolean enabled(UUID account) {
/** Existing routine combat/effect feedback preference. */
public synchronized boolean enabled(UUID account) { return value(account, Preference.COMBAT_FEEDBACK); }
public synchronized boolean playerAurasEnabled(UUID account) { return value(account, Preference.PLAYER_AURAS); }
public synchronized boolean allParticlesEnabled(UUID account) { return value(account, Preference.ALL_PARTICLES); }
public synchronized boolean auraParticlesEnabled(UUID account) {
return value(account, Preference.ALL_PARTICLES) && value(account, Preference.PLAYER_AURAS);
}
private boolean value(UUID account, Preference preference) {
Objects.requireNonNull(account);
return effective.getOrDefault(account, true);
return effective.getOrDefault(new AccountPreference(account, preference), preference.defaultValue);
}
public CompletionStage<Update> set(UUID account, boolean enabled) {
Objects.requireNonNull(account);
return set(account, Preference.COMBAT_FEEDBACK, enabled);
}
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled) {
return set(account, Preference.PLAYER_AURAS, enabled);
}
/** Shared persisted precedence for the separately delivered all-particles command. */
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled) {
return set(account, Preference.ALL_PARTICLES, enabled);
}
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled) {
Objects.requireNonNull(account); Objects.requireNonNull(preference);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Player preference store is closed"));
pending++;
@@ -62,11 +96,11 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
try {
return CompletableFuture.supplyAsync(() -> {
try {
save(account, enabled);
return new Update(account, enabled, ++persistedSequence);
save(account, preference, enabled);
return new Update(account, preference, enabled, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(new IOException(
"Combat feedback preference save refused or failed; effective setting unchanged", failure));
"Player preference save refused or failed; effective setting unchanged", failure));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
@@ -78,34 +112,37 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (closed) return false;
long previous = published.getOrDefault(update.account(), 0L);
var key = new AccountPreference(update.account(), update.preference());
long previous = published.getOrDefault(key, 0L);
if (update.sequence() <= previous) return false;
effective.put(update.account(), update.enabled());
published.put(update.account(), update.sequence());
effective.put(key, update.enabled());
published.put(key, update.sequence());
return true;
}
private Map<UUID, Boolean> loadEnabled() throws IOException {
private Map<AccountPreference, Boolean> loadEnabled() throws IOException {
Map<String, Object> root = readRoot();
Object rawPlayers = root.get(PLAYERS);
if (rawPlayers == null) return Map.of();
if (!(rawPlayers instanceof Map<?, ?> players)) throw new IOException("Invalid player preferences");
var result = new LinkedHashMap<UUID, Boolean>();
var result = new LinkedHashMap<AccountPreference, Boolean>();
for (var entry : players.entrySet()) {
if (!(entry.getKey() instanceof String rawId)) throw new IOException("Invalid player preference identity");
UUID account;
try { account = UUID.fromString(rawId); }
catch (IllegalArgumentException invalid) { throw new IOException("Invalid player preference identity", invalid); }
if (!(entry.getValue() instanceof Map<?, ?> values)) throw new IOException("Invalid player preference entry");
Object raw = values.get(KEY);
if (raw == null) continue;
if (!(raw instanceof Boolean value)) throw new IOException("Invalid combat feedback preference");
result.put(account, value);
for (var preference : Preference.values()) {
Object raw = values.get(preference.key);
if (raw == null) continue;
if (!(raw instanceof Boolean value)) throw new IOException("Invalid " + preference.key + " preference");
result.put(new AccountPreference(account, preference), value);
}
}
return result;
}
private void save(UUID account, boolean enabled) throws IOException {
private void save(UUID account, Preference preference, boolean enabled) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe data directory");
Path lockFile = directory.resolve(".player-preferences.lock");
@@ -118,7 +155,7 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
Map<String, Object> root = readRoot();
Map<String, Object> players = stringMap(root.get(PLAYERS), "player preferences");
Map<String, Object> values = stringMap(players.get(account.toString()), "player preference entry");
values.put(KEY, enabled);
values.put(preference.key, enabled);
players.put(account.toString(), values);
root.put(PLAYERS, players);
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
@@ -0,0 +1,117 @@
package games.dmg.fantasystick.presentation;
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.entity.Player;
/** Bounded viewer-aware authority for every explicit Fantasy Stick-owned particle packet. */
public final class ParticleDelivery {
private static final double NATIVE_PARTICLE_RANGE_SQUARED = 32d * 32d;
private final Server server;
private final PlayerFeedbackPreferences preferences;
private final Consumer<Throwable> failures;
private final boolean legacyBroadcast;
public ParticleDelivery(Server server, PlayerFeedbackPreferences preferences, Consumer<Throwable> failures) {
this.server = Objects.requireNonNull(server);
this.preferences = Objects.requireNonNull(preferences);
this.failures = Objects.requireNonNull(failures);
this.legacyBroadcast = false;
}
private ParticleDelivery() {
server = null; preferences = null; failures = ignored -> { }; legacyBroadcast = true;
}
/** Compatibility for isolated effect tests; production constructs the viewer-aware authority. */
public static ParticleDelivery legacyBroadcast() { return new ParticleDelivery(); }
public void world(Particle particle, Location location, int count,
double offsetX, double offsetY, double offsetZ, double extra) {
emitWorld(particle, location, count, offsetX, offsetY, offsetZ, extra, null, false);
}
public void world(Particle particle, Location location, int count,
double offsetX, double offsetY, double offsetZ, double extra, Object data) {
emitWorld(particle, location, count, offsetX, offsetY, offsetZ, extra, data, true);
}
public void viewer(Player viewer, Particle particle, Location location, int count,
double offsetX, double offsetY, double offsetZ, double extra) {
emitViewer(viewer, particle, location, count, offsetX, offsetY, offsetZ, extra, null, false);
}
public void viewer(Player viewer, Particle particle, Location location, int count,
double offsetX, double offsetY, double offsetZ, double extra, Object data) {
emitViewer(viewer, particle, location, count, offsetX, offsetY, offsetZ, extra, data, true);
}
public void aura(Player source) {
Objects.requireNonNull(source);
Location location = source.getLocation().add(0, 1, 0);
var dust = new Particle.DustOptions(Color.BLUE, 1);
if (legacyBroadcast) {
source.getWorld().spawnParticle(Particle.DUST, location, 3, 0.3, 0.4, 0.3, 0, dust);
return;
}
var tracked = source.getTrackedBy();
for (var viewer : List.copyOf(server.getOnlinePlayers())) {
try {
if (!viewer.isOnline() || viewer.getWorld() != source.getWorld()
|| (!viewer.getUniqueId().equals(source.getUniqueId()) && !tracked.contains(viewer))
|| !viewer.canSee(source) || !preferences.auraParticlesEnabled(viewer.getUniqueId())) continue;
viewer.spawnParticle(Particle.DUST, location, 3, 0.3, 0.4, 0.3, 0, dust);
} catch (RuntimeException failure) { report(failure); }
}
}
private void emitWorld(Particle particle, Location location, int count,
double offsetX, double offsetY, double offsetZ, double extra,
Object data, boolean hasData) {
Objects.requireNonNull(particle); Objects.requireNonNull(location);
if (location.getWorld() == null) return;
if (legacyBroadcast) {
if (hasData) location.getWorld().spawnParticle(particle, location, count,
offsetX, offsetY, offsetZ, extra, data);
else location.getWorld().spawnParticle(particle, location, count,
offsetX, offsetY, offsetZ, extra);
return;
}
for (var viewer : List.copyOf(server.getOnlinePlayers())) {
try {
if (!viewer.isOnline() || viewer.getWorld() != location.getWorld()
|| viewer.getLocation().distanceSquared(location) > NATIVE_PARTICLE_RANGE_SQUARED
|| !preferences.allParticlesEnabled(viewer.getUniqueId())) continue;
send(viewer, particle, location, count, offsetX, offsetY, offsetZ, extra, data, hasData);
} catch (RuntimeException failure) { report(failure); }
}
}
private void emitViewer(Player viewer, Particle particle, Location location, int count,
double offsetX, double offsetY, double offsetZ, double extra,
Object data, boolean hasData) {
Objects.requireNonNull(viewer); Objects.requireNonNull(particle); Objects.requireNonNull(location);
if (!legacyBroadcast && (!viewer.isOnline() || location.getWorld() == null
|| viewer.getWorld() != location.getWorld()
|| !preferences.allParticlesEnabled(viewer.getUniqueId()))) return;
try { send(viewer, particle, location, count, offsetX, offsetY, offsetZ, extra, data, hasData); }
catch (RuntimeException failure) { report(failure); }
}
private static void send(Player viewer, Particle particle, Location location, int count,
double offsetX, double offsetY, double offsetZ, double extra,
Object data, boolean hasData) {
if (hasData) viewer.spawnParticle(particle, location, count, offsetX, offsetY, offsetZ, extra, data);
else viewer.spawnParticle(particle, location, count, offsetX, offsetY, offsetZ, extra);
}
private void report(Throwable failure) {
try { failures.accept(failure); } catch (RuntimeException ignored) { }
}
}
@@ -25,16 +25,24 @@ public final class AdminExperienceGrant implements AutoCloseable {
private final Consumer<Runnable> nextTick;
private final Function<UUID, Player> players;
private final Consumer<Failure> failures;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private volatile boolean closed;
public AdminExperienceGrant(CharacterStorage characters, ProgressionRules progression, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Function<UUID, Player> players, Consumer<Failure> failures) {
this(characters, progression, sessions, nextTick, players, failures,
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public AdminExperienceGrant(CharacterStorage characters, ProgressionRules progression, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Function<UUID, Player> players, Consumer<Failure> failures,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.characters = Objects.requireNonNull(characters);
this.progression = Objects.requireNonNull(progression);
this.sessions = Objects.requireNonNull(sessions);
this.nextTick = Objects.requireNonNull(nextTick);
this.players = Objects.requireNonNull(players);
this.failures = Objects.requireNonNull(failures);
this.particles = Objects.requireNonNull(particles);
}
/** Returns false before persistence when the captured active identity is no longer authoritative. */
@@ -99,7 +107,8 @@ public final class AdminExperienceGrant implements AutoCloseable {
for (int gained = 1; gained <= levels; gained++) {
int level = saved.level() - levels + gained;
player.sendMessage("Level " + level + "! Gained 1 character point.");
SessionVisuals.burst(player, Color.BLUE, 12);
particles.world(org.bukkit.Particle.DUST, player.getLocation().add(0, 1, 0), 12,
0.3, 0.4, 0.3, 0, new org.bukkit.Particle.DustOptions(Color.BLUE, 1));
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
}
} catch (RuntimeException failure) { report(saved.id(), failure); }
@@ -92,6 +92,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
private games.dmg.fantasystick.book.CharacterBookController characterBooks;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -379,6 +380,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
characterIssuer = Objects.requireNonNull(issuer);
}
public void attachCharacterBooks(games.dmg.fantasystick.book.CharacterBookController books) {
if (closed || characterBooks != null) throw new IllegalStateException("Character books already attached or command closed");
characterBooks = Objects.requireNonNull(books);
}
public void attachPartyRegistrars(games.dmg.fantasystick.party.PartyRegistrarDirectory directory,
games.dmg.fantasystick.party.PartyRegistrarController controller) {
if (closed || partyRegistrars != null) throw new IllegalStateException("Party Registrars already attached or command closed");
@@ -415,6 +421,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("quest-giver")) {
adventureQuestCommands.giver(sender, args); return true;
}
if (args.length > 0 && args[0].equals("character")) {
characterView(sender, args); return true;
}
if(dungeonCommands!=null&&args.length>0&&args[0].equals("dungeon")){dungeonCommands.execute(sender,args);return true;}
if(args.length>0&&args[0].equals("shop")){shop(sender,args);return true;}
if (args.length > 0 && args[0].equals("training-dummy")) {
@@ -497,6 +506,23 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
return true;
}
private void characterView(CommandSender sender, String[] args) {
if (characterBooks == null || !(sender instanceof org.bukkit.entity.Player administrator)
|| args.length != 3 || !args[1].equals("view") || args[2].isBlank()) {
sender.sendMessage("Usage: /fantasystick character view <online-player> (in-game administrator only)");
return;
}
var matches = server.getOnlinePlayers().stream()
.filter(org.bukkit.entity.Player::isOnline)
.filter(player -> player.getName().equals(args[2]))
.toList();
if (matches.size() != 1) {
sender.sendMessage("Character view refused: target must exactly name one online player.");
return;
}
characterBooks.openReadOnly(administrator, matches.getFirst());
}
private void experience(CommandSender sender, String[] args) {
if (experienceGrant == null || args.length < 4 || !args[1].equals("give")) {
sender.sendMessage("Usage: /fantasystick experience give <active-character-name...> <positive-amount>"); return;
@@ -1438,8 +1464,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (!sender.hasPermission(PERMISSION)) return List.of();
if(dungeonCommands!=null&&args.length>1){var dungeon=dungeonCommands.complete(sender,args);if(!dungeon.isEmpty())return dungeon;if(args[0].equals("dungeon"))return List.of();}
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
? List.of("adventure", "adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "revive", "shop", "training-dummy")
? List.of("adventure", "adventure-site", "character", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "revive", "shop", "training-dummy")
: List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"), args[0]);
if (args.length == 2 && args[0].equals("character") && sender instanceof org.bukkit.entity.Player)
return matching(List.of("view"), args[1]);
if (args.length == 3 && args[0].equals("character") && args[1].equals("view")
&& sender instanceof org.bukkit.entity.Player)
return matching(server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
.filter(player -> sessions.active(player.getUniqueId()).filter(sessions::current).isPresent())
.map(org.bukkit.entity.Player::getName).toList(), args[2]);
if(args.length==2&&args[0].equals("shop"))return matching(sender instanceof org.bukkit.entity.Player?List.of("list","place","remove","restock"):List.of("list","remove","restock"),args[1]);
if(args.length==3&&args[0].equals("shop")&&args[1].equals("remove")&&armorers!=null)return matching(armorers.all().stream().map(value->value.id().toString()).toList(),args[2]);
if (args.length == 2 && args[0].equals("training-dummy")) return matching(
@@ -752,7 +752,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
UUID owner = event.getPlayer().getUniqueId();
cancelCustody(owner);
swaps.remove(owner);
sessions.invalidateTail(owner);
sessions.invalidateTail(owner, event.getView());
}
private boolean reservedExchange(Player player, int slot) {
@@ -114,7 +114,12 @@ public final class EquipmentSessions implements AutoCloseable {
boolean bookDelivered;
boolean deliveryInvalidated;
boolean conversionReported;
boolean partyDefeatAcknowledged;
boolean tailCallbackQueued;
boolean defeatReturnTeleport;
CharacterRecord deathRecord;
ItemStack deathReference;
Object deathNativeReference;
org.bukkit.inventory.InventoryView deathView;
Attempt(Player player, UUID owner, UUID character) {
this.player = player; this.owner = owner; this.character = character;
@@ -426,7 +431,12 @@ public final class EquipmentSessions implements AutoCloseable {
}
if (active.containsKey(owner)) return;
cancelEntry(owner);
if (owned.containsKey(owner)) {
var previous = owned.get(owner);
if (previous != null && previous.defeated) {
retryTail(player, previous);
return;
}
if (previous != null) {
notice(owner, "BUSY", "Equipment persistence or recovery is pending; retry after it completes.");
return;
}
@@ -673,30 +683,35 @@ public final class EquipmentSessions implements AutoCloseable {
if (failure != null) { diagnostic(attempt, failure); conversionUncertain(attempt, "Death save failed or is uncertain"); return; }
if (dead.isEmpty()) { conversionUncertain(attempt, "Character was missing or already dead; no new Tail authorized"); return; }
attempt.deathResolved = true;
attempt.deathRecord = dead.orElseThrow();
if (closed) { conversionUncertain(attempt, "Shutdown before party defeat and Tail conversion"); return; }
java.util.concurrent.CompletionStage<?> partyMutation;
try { partyMutation = partyDefeat.apply(attempt.character); }
catch (RuntimeException error) { diagnostic(attempt,error); conversionUncertain(attempt,"Party defeat mutation rejected"); return; }
partyMutation.whenComplete((ignored,partyFailure) -> {
if (partyFailure != null) { diagnostic(attempt,partyFailure); conversionUncertain(attempt,"Party defeat mutation failed"); return; }
if (closed) { conversionUncertain(attempt,"Shutdown before Tail conversion"); return; }
try {
nextTick.accept(() -> {
synchronized (EquipmentSessions.this) { deliverTail(attempt, dead.orElseThrow()); }
});
} catch (RuntimeException error) { diagnostic(attempt, error); conversionUncertain(attempt, "Tail callback rejected"); }
synchronized (EquipmentSessions.this) {
if (partyFailure != null) { diagnostic(attempt,partyFailure); conversionUncertain(attempt,"Party defeat mutation failed"); return; }
if (closed) { conversionUncertain(attempt,"Shutdown before Tail conversion"); return; }
attempt.partyDefeatAcknowledged = true;
queueTail(attempt);
}
});
});
try {
attempt.deathReference = copy(attempt.player.getInventory().getItemInOffHand());
attempt.deathView = attempt.player.getOpenInventory();
restoreExit(attempt);
var restored = restoreExit(attempt);
if (restored == EquipmentStorage.RestoreResult.RESTORED) {
var retained = attempt.player.getInventory().getItemInOffHand();
attempt.deathReference = copy(retained);
attempt.deathNativeReference = nativeIdentity(retained);
attempt.deathView = attempt.player.getOpenInventory();
}
// Read the current maximum after owned overlay cleanup, including legitimate
// ordinary modifiers. Never reconstruct attributes from an entry snapshot.
double maximum = Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();
event.setReviveHealth(maximum);
attempt.player.setHealth(maximum); // Native cancellation only fills health when <= 0.
notice(attempt.owner, "DEFEAT", "§cYour character was defeated.");
attempt.defeatReturnTeleport = true;
try {
if (!attempt.player.teleport(attempt.entryLocation.clone())) {
notice(attempt.owner, "DEFEAT_RETURN_FAILED", "Return to the session entry location was refused; you remain here.");
@@ -704,6 +719,10 @@ public final class EquipmentSessions implements AutoCloseable {
} catch (RuntimeException failure) {
diagnostic(attempt, failure);
notice(attempt.owner, "DEFEAT_RETURN_FAILED", "Return to the session entry location failed; you remain here.");
} finally {
attempt.defeatReturnTeleport = false;
if (!attempt.deliveryInvalidated && owned.get(attempt.owner) == attempt && attempt.player.isOnline())
attempt.deathView = attempt.player.getOpenInventory();
}
} catch (RuntimeException failure) {
diagnostic(attempt, failure);
@@ -727,21 +746,66 @@ public final class EquipmentSessions implements AutoCloseable {
if (attempt != null && attempt.defeated) attempt.deliveryInvalidated = true;
}
private void deliverTail(Attempt attempt, CharacterRecord dead) {
if (attempt.bookAttempted) return;
attempt.bookAttempted = true; // Even an exception after a native write never permits replay.
/** The exact view closed by this plugin's defeat-return teleport may be rebound after teleport. */
public synchronized void invalidateTail(UUID owner, org.bukkit.inventory.InventoryView closedView) {
var attempt = owned.get(owner);
if (attempt == null || !attempt.defeated) return;
if (attempt.defeatReturnTeleport && attempt.deathView == closedView) {
attempt.deathView = null;
return;
}
attempt.deliveryInvalidated = true;
}
private void queueTail(Attempt attempt) {
if (closed || attempt.tailCallbackQueued || attempt.bookAttempted || attempt.bookDelivered
|| attempt.deliveryInvalidated || !attempt.deathResolved || !attempt.partyDefeatAcknowledged
|| !attempt.gearAcknowledged || attempt.deathRecord == null) return;
attempt.tailCallbackQueued = true;
try {
if (!tailContext(attempt)
|| !Objects.equals(attempt.deathReference, copy(attempt.player.getInventory().getItemInOffHand()))
|| !sheets.identify(attempt.deathReference).filter(attempt.character::equals).isPresent()
|| attempt.deathReference.getAmount() < 1
|| attempt.deathReference.getAmount() > attempt.deathReference.getMaxStackSize()) {
nextTick.accept(() -> {
synchronized (EquipmentSessions.this) {
attempt.tailCallbackQueued = false;
deliverTail(attempt, attempt.deathRecord);
}
});
} catch (RuntimeException error) {
attempt.tailCallbackQueued = false;
conversionNotStarted(attempt, "Tail callback was rejected before its native write ("
+ error.getClass().getSimpleName() + ")");
}
}
private void retryTail(Player player, Attempt attempt) {
if (attempt.player != player || !player.isOnline() || attempt.bookAttempted || attempt.bookDelivered
|| attempt.deliveryInvalidated || !tailCustody(attempt)) {
attempt.deliveryInvalidated = true;
notice(attempt.owner, "DEFEAT_INSPECTION_REQUIRED",
"Death or Tail custody is ambiguous; items were retained for administrator inspection.");
return;
}
if (!attempt.deathResolved || !attempt.partyDefeatAcknowledged || !attempt.gearAcknowledged
|| attempt.deathRecord == null) {
notice(attempt.owner, "DEFEAT_PENDING",
"Death persistence, restoration or Tail prerequisites are still pending; no item was changed.");
return;
}
attempt.tailCallbackQueued = false;
deliverTail(attempt, attempt.deathRecord);
}
private void deliverTail(Attempt attempt, CharacterRecord dead) {
if (attempt.bookAttempted || attempt.bookDelivered) return;
try {
if (!tailContext(attempt) || !tailCustody(attempt)) {
conversionUncertain(attempt, "Tail destination/context is stale or equipment restoration is unresolved"); return;
}
// Constructing a book cannot mutate native inventory and remains retryable if it fails.
var book = new games.dmg.fantasystick.items.TailBooks().create(dead);
attempt.bookAttempted = true; // From this point an exception may follow a native write; never replay.
// An exact captured stack of copied references represents ONE character, not N
// deaths. Replace that whole stack with ONE book; never scan/delete other copies,
// move items, overwrite a changed stack, or grant into an available slot.
var book = new games.dmg.fantasystick.items.TailBooks().create(dead);
attempt.player.getInventory().setItemInOffHand(book);
if (!tailContext(attempt) || !book.equals(attempt.player.getInventory().getItemInOffHand())) {
conversionUncertain(attempt, "Context changed during Tail write; delivery may already have happened"); return;
@@ -750,11 +814,24 @@ public final class EquipmentSessions implements AutoCloseable {
release(attempt);
sync(attempt);
} catch (RuntimeException failure) {
diagnostic(attempt, failure);
conversionUncertain(attempt, "Tail native write failed or is uncertain; never replay");
if (attempt.bookAttempted) {
diagnostic(attempt, failure);
conversionUncertain(attempt, "Tail native write failed or is uncertain; never replay");
} else conversionNotStarted(attempt, "Tail construction failed before its native write ("
+ failure.getClass().getSimpleName() + ")");
}
}
private boolean tailCustody(Attempt attempt) {
var current = attempt.player.getInventory().getItemInOffHand();
return attempt.deathReference != null && attempt.deathNativeReference != null
&& Objects.equals(attempt.deathReference, copy(current))
&& nativeIdentity(current) == attempt.deathNativeReference
&& sheets.identify(attempt.deathReference).filter(attempt.character::equals).isPresent()
&& attempt.deathReference.getAmount() >= 1
&& attempt.deathReference.getAmount() <= attempt.deathReference.getMaxStackSize();
}
private boolean tailContext(Attempt attempt) {
if (closed || attempt.deliveryInvalidated || owned.get(attempt.owner) != attempt
|| !attempt.player.isOnline() || attempt.player.getOpenInventory() != attempt.deathView) return false;
@@ -763,6 +840,11 @@ public final class EquipmentSessions implements AutoCloseable {
&& marker.phase() == EquipmentSessionMarker.Phase.RESTORED;
}
private void conversionNotStarted(Attempt attempt, String reason) {
diagnostic(attempt, new IllegalStateException(reason + "; character " + attempt.character
+ ". The exact same-lifetime retained sheet may retry after acknowledged prerequisites."));
}
private void conversionUncertain(Attempt attempt, String reason) {
if (attempt.conversionReported) return;
attempt.conversionReported = true;
@@ -904,8 +986,18 @@ public final class EquipmentSessions implements AutoCloseable {
? receipts.completeExit(attempt.owner, attempt.session, attempt.exitDelay)
: receipts.recordExit(attempt.owner, attempt.session, attempt.exitDelay), (state, failure) -> {
attempt.saving = false;
if (failure != null) diagnostic(attempt, failure);
else if (complete) { attempt.gearAcknowledged = true; release(attempt); }
if (failure != null) {
diagnostic(attempt, failure);
if (attempt.defeated) conversionUncertain(attempt,
"Equipment restoration persistence failed before Tail conversion");
} else if (complete) {
attempt.gearAcknowledged = true;
if (attempt.defeated) queueTail(attempt);
release(attempt);
} else if (attempt.defeated) {
conversionUncertain(attempt,
"Equipment restoration was not acknowledged; Tail conversion remains unsafe");
}
});
}
@@ -999,6 +1091,9 @@ public final class EquipmentSessions implements AutoCloseable {
});
} catch (RuntimeException failure) { fail(attempt, failure); }
}
private static Object nativeIdentity(ItemStack item) {
return item == null || item.isEmpty() ? null : org.bukkit.craftbukkit.inventory.CraftItemStack.unwrap(item);
}
private static ItemStack copy(ItemStack item) { return item == null ? null : item.clone(); }
private static ItemStack[] snapshot(Player player) {
return Arrays.stream(player.getInventory().getContents()).map(EquipmentSessions::copy).toArray(ItemStack[]::new);
@@ -0,0 +1,98 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabExecutor;
import org.bukkit.entity.Player;
/** Ordinary-player command for durable account-owned Fantasy Stick particle preferences. */
public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable {
private final PlayerFeedbackPreferences preferences;
private final Consumer<Runnable> nextTick;
private final Consumer<Throwable> failures;
private boolean closed;
public FantasyParticlesCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
Consumer<Throwable> failures) {
this.preferences = Objects.requireNonNull(preferences);
this.nextTick = Objects.requireNonNull(nextTick);
this.failures = Objects.requireNonNull(failures);
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Fantasy particles are a player-account preference.");
return true;
}
if (args.length == 1 && (args[0].equals("players") || args[0].equals("all"))) {
player.sendMessage(status(player.getUniqueId(), args[0]));
return true;
}
if (args.length != 2 || !(args[0].equals("players") || args[0].equals("all"))
|| !(args[1].equals("on") || args[1].equals("off"))) return false;
String route = args[0];
boolean enabled = args[1].equals("on");
UUID account = player.getUniqueId();
var save = route.equals("players")
? preferences.setPlayerAuras(account, enabled) : preferences.setAllParticles(account, enabled);
save.whenComplete((saved, failure) -> {
if (failure != null) report(failure);
try {
nextTick.accept(() -> {
if (closed) return;
if (failure != null) {
if (player.isOnline()) player.sendMessage(
(route.equals("players") ? "Active-character aura" : "All-particles")
+ " preference could not be saved; " + status(account, route));
return;
}
boolean published = preferences.publish(saved);
if (player.isOnline()) player.sendMessage(acknowledgement(account, route, published));
});
} catch (RuntimeException rejected) { report(rejected); }
});
return true;
}
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (!(sender instanceof Player)) return List.of();
if (args.length == 1) return matching(List.of("all", "players"), args[0]);
if (args.length == 2 && (args[0].equals("players") || args[0].equals("all")))
return matching(List.of("off", "on"), args[1]);
return List.of();
}
private String status(UUID account, String route) {
if (route.equals("all"))
return "All Fantasy Stick particles are " + state(preferences.allParticlesEnabled(account)) + ".";
boolean saved = preferences.playerAurasEnabled(account);
if (saved && !preferences.allParticlesEnabled(account))
return "Active-character aura particles are on (effectively off while all particles are off).";
return "Active-character aura particles are " + state(saved) + ".";
}
private String acknowledgement(UUID account, String route, boolean published) {
if (route.equals("all")) return "All Fantasy Stick particles are "
+ (published ? "now " : "already ") + state(preferences.allParticlesEnabled(account)) + ".";
boolean saved = preferences.playerAurasEnabled(account);
String prefix = "Active-character aura particles are " + (published ? "now " : "already ") + state(saved);
if (saved && !preferences.allParticlesEnabled(account))
return prefix + " (effectively off while all particles are off).";
return prefix + ".";
}
private static List<String> matching(List<String> values, String rawPrefix) {
String prefix = rawPrefix.toLowerCase(Locale.ROOT);
return values.stream().filter(value -> value.startsWith(prefix)).toList();
}
private static String state(boolean enabled) { return enabled ? "on" : "off"; }
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
@Override public void close() { closed = true; }
}
@@ -11,28 +11,34 @@ import org.bukkit.scheduler.BukkitTask;
/** Public particles only; never grants gameplay state, protection, health or persistence authority. */
public final class SessionVisuals implements AutoCloseable {
private final EquipmentSessions sessions;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Consumer<Throwable> failures;
private BukkitTask task;
private boolean closed;
public SessionVisuals(EquipmentSessions sessions, Consumer<Throwable> failures) {
this.sessions = Objects.requireNonNull(sessions); this.failures = Objects.requireNonNull(failures);
public SessionVisuals(EquipmentSessions sessions,
games.dmg.fantasystick.presentation.ParticleDelivery particles,
Consumer<Throwable> failures) {
this.sessions = Objects.requireNonNull(sessions);
this.particles = Objects.requireNonNull(particles);
this.failures = Objects.requireNonNull(failures);
}
public void install(Plugin plugin) {
if (closed || task != null) throw new IllegalStateException("Visual lifetime already used");
task = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
if (closed) return;
for (var player : plugin.getServer().getOnlinePlayers()) {
for (var source : java.util.List.copyOf(plugin.getServer().getOnlinePlayers())) {
try {
var active = sessions.active(player.getUniqueId());
if (active.isPresent() && sessions.current(active.orElseThrow())) burst(player, Color.BLUE, 3);
var active = sessions.active(source.getUniqueId());
if (active.isPresent() && sessions.current(active.orElseThrow())) particles.aura(source);
} catch (RuntimeException failure) { failures.accept(failure); }
}
}, 1L, 10L);
}
public static void burst(Player player, Color color, int count) {
player.getWorld().spawnParticle(Particle.DUST, player.getLocation().add(0, 1, 0), count,
0.3, 0.4, 0.3, 0, new Particle.DustOptions(color, 1));
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast().world(Particle.DUST,
player.getLocation().add(0, 1, 0), count, 0.3, 0.4, 0.3, 0,
new Particle.DustOptions(color, 1));
}
@Override public void close() {
closed = true;
+4 -1
View File
@@ -8,11 +8,14 @@ author: dmg.games
commands:
fantasystick:
description: Inspect daily adventures or administer Fantasy Stick.
usage: /fantasystick adventure [reroll confirm] | adventure-site <place <camp|champion> [name...]|remove <site-uuid>|teleport <site-uuid>|list> | dungeon <teleport <dungeon-uuid>|...> | quest-giver <place [name...]|remove <giver-uuid>|list> | <admin route>
usage: /fantasystick adventure [reroll confirm] | character view <online-player> | adventure-site <place <camp|champion> [name...]|remove <site-uuid>|teleport <site-uuid>|list> | dungeon <teleport <dungeon-uuid>|...> | quest-giver <place [name...]|remove <giver-uuid>|list> | <admin route>
fantasyfeedback:
description: Read or change routine Fantasy Stick combat and ability feedback for your player account.
usage: /fantasyfeedback [on|off]
fantasyparticles:
description: Read or change Fantasy Stick particle visibility for your player account.
usage: /fantasyparticles <all|players> [on|off]
permissions:
fantasystick.admin:
description: Issue/revive sheets, grant fantasy experience, recover sessions and configure fantasy mode.
@@ -0,0 +1,366 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import net.minecraft.network.chat.ClickEvent;
import net.minecraft.network.chat.Component;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Actual registered admin command through authoritative storage and native virtual-book output. */
class AdminCharacterBookNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredCommandOpensTheTargetsAuthoritativeReadOnlyBook() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
admin.accountName = "Admin";
target.accountName = "Target";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
target.core.health = 7.0;
org.bukkit.inventory.ItemStack[] adminInventory = admin.core.snapshot();
org.bukkit.inventory.ItemStack[] targetInventory = target.core.snapshot();
Path character = data.resolve("characters").resolve(target.core.record.id() + ".yml");
byte[] savedCharacter = Files.readAllBytes(character);
assertTrue(fixture.command.execute(admin.player, "fantasystick",
new String[]{"character", "view", "Target"}));
assertTrue(admin.books.isEmpty(), "Authoritative storage must complete before opening");
assertTrue(admin.messages.isEmpty(), admin.messages::toString);
var controller = FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "books");
assertEquals(1, ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(controller,
"adminPending")).size());
characters(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
fixture.tick();
assertEquals(1, admin.books.size());
assertTrue(target.books.isEmpty(), "Inspection must not interrupt the target");
var pages = nativePages(admin);
String text = pages.stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining("\n"));
assertTrue(text.replace("\n", "").contains(target.core.record.name()));
assertTrue(text.contains("Health: 7/10"), text);
assertTrue(text.contains("Active abilities"));
assertTrue(text.contains("Passive abilities"));
assertTrue(text.contains("Party"));
assertFalse(text.contains("[+1") || text.contains("[Learn]") || text.contains("[Unlearn]"));
assertTrue(pages.stream().allMatch(AdminCharacterBookNativeTest::onlyNativePageLinks));
dispatchCustom(fixture, admin, "fantasystick:character_stat",
"\"" + UUID.randomUUID() + ":STR\"");
dispatchCustom(fixture, admin, "fantasystick:ability_unlock",
"\"" + UUID.randomUUID() + ":fireball\"");
assertArrayEquals(adminInventory, admin.core.snapshot());
assertArrayEquals(targetInventory, target.core.snapshot());
assertArrayEquals(savedCharacter, Files.readAllBytes(character));
}
}
@Test void largeAuthoritativeRecordsRemainCompleteThroughBoundedReadOnlyNavigation() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var target = new FantasyStickLifecycleNativeTest.Actor(data); target.accountName = "Target";
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(target.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
String digits = "1234567890".repeat(4_000);
stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
var cooldowns = new java.util.HashMap<>(old.cooldowns());
cooldowns.put("unknown_saved", java.time.Instant.parse("2030-01-02T03:04:05Z"));
var large = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(),
old.unspentPoints(), old.health(), java.util.Set.of("unknown_saved"),
java.util.List.of("unknown_saved", "", "", "", "", "", ""), cooldowns,
old.restrictedKills(), false, null, null);
repository.replace(large, old.revision());
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
Path character = data.resolve("characters").resolve(target.core.record.id() + ".yml");
byte[] savedCharacter = Files.readAllBytes(character);
org.bukkit.inventory.ItemStack[] adminInventory = admin.core.snapshot();
org.bukkit.inventory.ItemStack[] targetInventory = target.core.snapshot();
fixture.command.execute(admin.player, "fantasystick",
new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
var all = new StringBuilder();
var content = new StringBuilder();
int volumes = 0;
while (true) {
volumes++;
var pages = nativePages(admin);
assertTrue(pages.size() <= 100, "Every administrator view must stay bounded");
pages.forEach(page -> {
String text = page.getString();
all.append(text);
if (!text.startsWith("Character pages\n\n")) content.append(text);
});
var next = customLinkOrNull(pages, "[Next pages]");
if (next == null) break;
int before = admin.books.size();
PartyBookNativeTest.click(fixture, admin, next);
if (volumes == 1) PartyBookNativeTest.click(fixture, admin, next);
drainReads(fixture); runQueued(fixture);
assertEquals(before + 1, admin.books.size(), "Repeated navigation payloads are single-use");
}
assertTrue(volumes > 1);
assertTrue(content.toString().replace("\n", "").contains("STR: " + digits + "DEX:"),
"Every persisted stat digit must remain reachable in order across bounded volumes");
assertTrue(all.toString().contains("Gear"));
assertTrue(all.toString().contains("Active abilities"));
assertTrue(all.toString().contains("unknown_saved"));
assertTrue(all.toString().contains("Party"));
assertArrayEquals(adminInventory, admin.core.snapshot());
assertArrayEquals(targetInventory, target.core.snapshot());
assertArrayEquals(savedCharacter, Files.readAllBytes(character));
}
}
@Test void accountNameSelectionSupportsSelfAndDuplicateFantasyNames() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var other = new FantasyStickLifecycleNativeTest.Actor(data); other.accountName = "Other";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(other.core.id, other.player);
var storage = characters(fixture);
var renamedAdmin = storage.renameCharacter(admin.core.record.id(), "Duplicate Fantasy Name")
.get(5, TimeUnit.SECONDS).orElseThrow();
var renamedOther = storage.renameCharacter(other.core.record.id(), "Duplicate Fantasy Name")
.get(5, TimeUnit.SECONDS).orElseThrow();
admin.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamedAdmin));
other.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamedOther));
fixture.activate(admin); fixture.activate(other);
admin.core.health = 6.0; other.core.health = 8.0;
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Admin"});
drainReads(fixture); runQueued(fixture);
assertTrue(text(admin).contains("Health: 6/10"));
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Other"});
drainReads(fixture); runQueued(fixture);
assertTrue(text(admin).contains("Health: 8/10"));
assertEquals(2, admin.books.size());
}
}
@Test void stalePermissionSessionAndSupersededReadsNeverOpenCharacterData() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var first = new FantasyStickLifecycleNativeTest.Actor(data); first.accountName = "First";
var second = new FantasyStickLifecycleNativeTest.Actor(data); second.accountName = "Second";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(first.core.id, first.player);
fixture.players.put(second.core.id, second.player);
fixture.activate(first); fixture.activate(second);
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "First"});
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Second"});
drainReads(fixture); runQueued(fixture);
assertEquals(1, admin.books.size(), "Only the latest authoritative request may open");
assertTrue(nativePages(admin).stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining()).contains(second.core.record.name()));
int opened = admin.books.size();
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "First"});
drainReads(fixture);
var permissionResponse = fixture.tasks.poll(); assertNotNull(permissionResponse);
admin.allowed = false; permissionResponse.run();
assertEquals(opened, admin.books.size());
assertTrue(admin.messages.getLast().contains("permission changed"));
admin.allowed = true;
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "First"});
drainReads(fixture);
var sessionResponse = fixture.tasks.poll(); assertNotNull(sessionResponse);
((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions")).exit(first.player);
sessionResponse.run();
assertEquals(opened, admin.books.size());
assertTrue(admin.messages.getLast().contains("session changed"));
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Second"});
drainReads(fixture);
var retiredLifetimeResponse = fixture.tasks.poll(); assertNotNull(retiredLifetimeResponse);
fixture.plugin.setEnabled(false);
retiredLifetimeResponse.run();
assertEquals(opened, admin.books.size(), "A retired plugin lifetime cannot open character data");
}
}
@Test void malformedAuthoritativeReadReportsFailureAndAllowsAValidRetry() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var target = new FantasyStickLifecycleNativeTest.Actor(data); target.accountName = "Target";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
Path character = data.resolve("characters").resolve(target.core.record.id() + ".yml");
byte[] valid = Files.readAllBytes(character);
Files.writeString(character, "schema-version: malformed\n");
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
assertTrue(admin.books.isEmpty());
assertTrue(admin.messages.getLast().contains("could not be loaded"));
assertEquals("schema-version: malformed\n", Files.readString(character));
Files.write(character, valid);
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
assertEquals(1, admin.books.size());
assertArrayEquals(valid, Files.readAllBytes(character));
}
}
@Test void deadAndMissingAuthoritativeCharactersAreRefusedWithoutOpening() throws Exception {
for (String state : java.util.List.of("dead", "missing")) {
Path dir = data.resolve(state);
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(dir)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(dir); admin.accountName = "Admin";
var target = new FantasyStickLifecycleNativeTest.Actor(dir); target.accountName = "Target";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
Path character = dir.resolve("characters").resolve(target.core.record.id() + ".yml");
if (state.equals("missing")) {
Files.delete(character);
} else {
var old = target.core.record;
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), old.stats(), old.level(), old.experience(),
old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(),
old.restrictedKills(), true, UUID.randomUUID(), java.time.Instant.parse("2026-09-23T00:00:00Z"));
new games.dmg.fantasystick.persistence.YamlCharacterRepository(dir)
.replace(dead, old.revision());
}
fixture.command.execute(admin.player, "fantasystick",
new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
assertTrue(admin.books.isEmpty());
assertTrue(admin.messages.getLast().contains("unavailable"));
}
}
}
@Test void commandAndCompletionRequireAnAuthorizedOnlinePlayerAndExactActiveAccount() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var active = new FantasyStickLifecycleNativeTest.Actor(data); active.accountName = "ActiveTarget";
var inactive = new FantasyStickLifecycleNativeTest.Actor(data); inactive.accountName = "InactiveTarget";
var offline = new FantasyStickLifecycleNativeTest.Actor(data); offline.accountName = "OfflineTarget";
offline.core.online = false;
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(active.core.id, active.player);
fixture.players.put(inactive.core.id, inactive.player);
fixture.players.put(offline.core.id, offline.player);
fixture.activate(active);
assertEquals(java.util.List.of("character", "config"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"c"}));
assertEquals(java.util.List.of("view"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", ""}));
assertEquals(java.util.List.of("ActiveTarget"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", "view", ""}));
assertEquals(java.util.List.of("ActiveTarget"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", "view", "Act"}));
assertTrue(fixture.command.tabComplete(admin.player, "fantasystick",
new String[]{"character", "view", "ActiveTarget", ""}).isEmpty());
admin.allowed = false;
assertTrue(fixture.command.tabComplete(admin.player, "fantasystick",
new String[]{"character", ""}).isEmpty());
fixture.command.getExecutor().onCommand(admin.player, fixture.command, "fantasystick",
new String[]{"character", "view", "ActiveTarget"});
assertTrue(admin.books.isEmpty()); assertTrue(fixture.tasks.isEmpty());
admin.allowed = true;
var console = new EquipmentAdminCommandNativeTest.Sender();
assertTrue(fixture.command.tabComplete(console.port, "fantasystick",
new String[]{"character", ""}).isEmpty());
fixture.command.execute(console.port, "fantasystick",
new String[]{"character", "view", "ActiveTarget"});
assertTrue(console.messages.getLast().contains("in-game administrator"));
for (String[] args : java.util.List.of(
new String[]{"character"},
new String[]{"character", "view"},
new String[]{"character", "view", "activetarget"},
new String[]{"character", "view", "InactiveTarget"},
new String[]{"character", "view", "OfflineTarget"},
new String[]{"character", "view", "ActiveTarget", "extra"})) {
fixture.command.execute(admin.player, "fantasystick", args);
assertTrue(admin.books.isEmpty()); assertTrue(fixture.tasks.isEmpty());
}
}
}
private static void dispatchCustom(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor,
String key, String payload) {
var connection = org.mockito.Mockito.mock(io.papermc.paper.connection.PlayerGameConnection.class);
org.mockito.Mockito.when(connection.getPlayer()).thenReturn(actor.player);
fixture.dispatcher.accept(new io.papermc.paper.event.player.PaperPlayerCustomClickEvent(
net.kyori.adventure.key.Key.key(key), connection,
net.minecraft.nbt.StringTag.valueOf(payload)));
}
private static net.minecraft.network.chat.ClickEvent.Custom customLinkOrNull(
java.util.List<Component> pages, String label) {
for (var page : pages) {
var click = PartyBookNativeTest.find(page, label);
if (click != null) return click;
}
return null;
}
private static void drainReads(FantasyStickLifecycleNativeTest.Fixture fixture) throws Exception {
characters(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
}
private static void runQueued(FantasyStickLifecycleNativeTest.Fixture fixture) {
Runnable task;
while ((task = fixture.tasks.poll()) != null) task.run();
}
private static games.dmg.fantasystick.persistence.CharacterStorage characters(
FantasyStickLifecycleNativeTest.Fixture fixture) {
return (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
}
private static String text(FantasyStickLifecycleNativeTest.Actor actor) {
return nativePages(actor).stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining("\n"));
}
private static java.util.List<Component> nativePages(FantasyStickLifecycleNativeTest.Actor actor) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content);
return content.pages().stream().map(page -> page.raw()).toList();
}
private static boolean onlyNativePageLinks(Component component) {
var click = component.getStyle().getClickEvent();
if (click != null && !(click instanceof ClickEvent.ChangePage)) return false;
return component.getSiblings().stream().allMatch(AdminCharacterBookNativeTest::onlyNativePageLinks);
}
}
@@ -23,6 +23,11 @@ class AdminExperienceNativeTest {
var storage = storage(f);
storage.renameCharacter(target.core.record.id(), "Mira 雪").get();
f.players.put(target.core.id, target.player); f.activate(target);
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(target.world);
f.players.put(viewer.core.id, viewer.player);
f.particlesCommand.execute(target.player, "fantasyparticles", new String[]{"all", "off"}); f.tick();
target.particleCalls.clear(); viewer.particleCalls.clear();
var sender = new EquipmentAdminCommandNativeTest.Sender();
var before = storage.find(target.core.record.id()).get().orElseThrow();
@@ -47,8 +52,8 @@ class AdminExperienceNativeTest {
assertTrue(target.messages.stream().anyMatch(message -> message.contains("Level 2")));
assertTrue(target.messages.stream().anyMatch(message -> message.contains("Level 3")));
assertEquals(2, target.sounds.size());
assertEquals(2, org.mockito.Mockito.mockingDetails(target.world).getInvocations().stream()
.filter(invocation -> invocation.getMethod().getName().equals("spawnParticle")).count());
assertTrue(target.particleCalls.isEmpty(), "The opted-out recipient receives no progression bursts");
assertEquals(2, viewer.particleCalls.size(), "An enabled nearby viewer retains both progression bursts");
PartyBookNativeTest.open(f, target);
String text = bookText(target.books.getLast());
assertTrue(text.contains("Level: 3"), text); assertTrue(text.contains("XP: 50/100"), text);
@@ -269,6 +269,15 @@ class AdventureQuestNativeTest {
assertTrue(Math.abs(Math.abs(dx) - 10) < 0.000001 || Math.abs(Math.abs(dz) - 15) < 0.000001);
assertTrue(Math.abs(dx) <= 10.000001 && Math.abs(dz) <= 15.000001);
}
var particlePreferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "feedbackPreferences");
assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(admin.core.id, false)
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)));
admin.particleCalls.clear(); for (Runnable pulse : List.copyOf(f.pulses)) pulse.run();
assertTrue(admin.particleCalls.isEmpty(), () -> "All-particle opt-out suppresses eligible adventure boundaries: "
+ admin.particleCalls.stream().map(call -> java.util.Arrays.toString(call)).toList());
assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(admin.core.id, true)
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)));
admin.core.location.setX(rectangle.x() + 65);
admin.particles.clear(); admin.particleCalls.clear();
for (Runnable pulse : List.copyOf(f.pulses)) pulse.run();
@@ -48,8 +48,16 @@ class ChainLightningNativeTest {
.when(caster.world).rayTraceEntities(any(Location.class), any(org.bukkit.util.Vector.class), eq(16.0),
org.mockito.ArgumentMatchers.<java.util.function.Predicate<org.bukkit.entity.Entity>>any());
doReturn(List.of(second)).when(first).getNearbyEntities(anyDouble(), anyDouble(), anyDouble()); doReturn(true).when(first).hasLineOfSight(second);
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(caster.world);
fixture.players.put(viewer.core.id, viewer.player);
fixture.particlesCommand.execute(caster.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(fixture);
caster.particleCalls.clear(); viewer.particleCalls.clear();
var damage = new LinkedHashMap<UUID, Double>(); nativeDamage(fixture, first, damage); nativeDamage(fixture, second, damage);
use(fixture, caster); drain(fixture, 2);
assertTrue(caster.particleCalls.isEmpty(), "The opted-out caster receives no Chain Lightning particles");
assertEquals(2, viewer.particleCalls.stream().filter(call -> call[0] == org.bukkit.Particle.ELECTRIC_SPARK
&& call[2].equals(18)).count(), "An enabled nearby viewer retains every chain burst");
assertFalse(damage.isEmpty(), caster.messages.toString());
assertEquals(13, damage.get(first.getUniqueId()), 1.0e-10); assertEquals(9.75, damage.get(second.getUniqueId()), 1.0e-10);
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 13 damage to Zombie."::equals).count());
@@ -39,6 +39,108 @@ class CombatExperienceNativeTest {
games.dmg.fantasystick.party.CombatExperience.coinTarget(witch));
}
@Test void tooCloseHostileDeathDiscardsItsAllocatedCoinsAndPublishesNoCoinFeedback() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"hostile-coin-drop-min: 10\nhostile-coin-drop-max: 10\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); f.activate(actor);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var first = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, actor.player, first, false); die(f, first); settle(f);
var eligible = record(f, actor);
int earnedCoinMessages = (int) actor.messages.stream().filter(message -> message.contains("character coins")).count();
assertEquals(1, earnedCoinMessages);
var near = mob(EntityType.ZOMBIE, new Location(world, 1, 64, 0));
hit(f, actor.player, near, false); die(f, near); settle(f);
var blocked = record(f, actor);
assertEquals(eligible.experience(), blocked.experience());
assertEquals(eligible.coins(), blocked.coins(), "Spacing-blocked combat coins are discarded");
assertEquals(earnedCoinMessages,
actor.messages.stream().filter(message -> message.contains("character coins")).count());
assertTrue(actor.messages.stream().anyMatch(message -> message.contains("too close")));
assertEquals(new java.math.BigDecimal("1.0"), blocked.restrictedKills().values().iterator().next().x(),
"Blocked attempts still advance the spacing reference");
}
}
@Test void everyRestrictedMobCoinClassUsesTheExistingExactAndCrossWorldSpacingGeometry() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"hostile-coin-drop-min: 12\nhostile-coin-drop-max: 12\n"
+ "boss-coin-drop-min: 12\nboss-coin-drop-max: 12\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var types = java.util.List.of(EntityType.ZOMBIE, EntityType.BEE, EntityType.WITCH,
EntityType.WITHER, EntityType.WARDEN, EntityType.ENDER_DRAGON);
for (var type : types) {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); f.activate(actor);
var firstWorld = mock(org.bukkit.World.class); when(firstWorld.getUID()).thenReturn(UUID.randomUUID());
var otherWorld = mock(org.bukkit.World.class); when(otherWorld.getUID()).thenReturn(UUID.randomUUID());
var initial = record(f, actor);
var first = mob(type, new Location(firstWorld, 0, 64, 0));
hit(f, actor.player, first, false); die(f, first); settle(f);
var eligible = record(f, actor);
var expectedCoins = type == EntityType.BEE ? java.math.BigInteger.ZERO : java.math.BigInteger.valueOf(12);
assertEquals(initial.coins().add(expectedCoins), eligible.coins(), type + " first kill");
int coinMessages = (int) actor.messages.stream().filter(message -> message.contains("character coins")).count();
var near = mob(type, new Location(firstWorld, 1, 64, 0));
hit(f, actor.player, near, false); die(f, near); settle(f);
var blocked = record(f, actor);
assertEquals(eligible.experience(), blocked.experience(), type + " near XP");
assertEquals(eligible.coins(), blocked.coins(), type + " near coins");
assertEquals(coinMessages,
actor.messages.stream().filter(message -> message.contains("character coins")).count(), type + " feedback");
assertEquals(new java.math.BigDecimal("1.0"), blocked.restrictedKills().get(firstWorld.getUID()).x());
var exact = mob(type, new Location(firstWorld, 4, 64, 0));
hit(f, actor.player, exact, false); die(f, exact); settle(f);
var boundary = record(f, actor);
assertTrue(boundary.experience().compareTo(blocked.experience()) > 0, type + " exact distance XP");
assertEquals(blocked.coins().add(expectedCoins), boundary.coins(), type + " exact distance coins");
var acrossWorld = mob(type, new Location(otherWorld, 0, 64, 0));
hit(f, actor.player, acrossWorld, false); die(f, acrossWorld); settle(f);
var independent = record(f, actor);
assertTrue(independent.experience().compareTo(boundary.experience()) > 0, type + " cross-world XP");
assertEquals(boundary.coins().add(expectedCoins), independent.coins(), type + " cross-world coins");
assertEquals(2, independent.restrictedKills().size());
}
}
}
@Test void partyRecipientsApplyIndependentSpacingWithoutRedistributingBlockedShares() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"hostile-coin-drop-min: 20\nhostile-coin-drop-max: 20\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a); f.activate(b);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var history = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, a.player, history, false); die(f, history); settle(f);
var beforeA = record(f, a); var beforeB = record(f, b);
var parties = PartyLifecycleNativeTest.parties(f); var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
var shared = mob(EntityType.ZOMBIE, new Location(world, 1, 64, 0));
hit(f, a.player, shared, false); die(f, shared); settle(f); settle(f);
var afterA = record(f, a); var afterB = record(f, b);
assertEquals(beforeA.experience(), afterA.experience());
assertEquals(beforeA.coins(), afterA.coins(), "A's blocked half is discarded");
assertEquals(beforeB.experience().add(java.math.BigInteger.valueOf(5)), afterB.experience());
assertEquals(beforeB.coins().add(java.math.BigInteger.TEN), afterB.coins(), "B retains only B's half");
assertEquals(new java.math.BigDecimal("1.0"), afterA.restrictedKills().get(world.getUID()).x());
assertEquals(new java.math.BigDecimal("1.0"), afterB.restrictedKills().get(world.getUID()).x());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("too close")));
assertTrue(b.messages.stream().anyMatch(message -> message.contains("10 character coins")));
}
}
@Test void packagedWitchPoolFlowsThroughExactSoloAndPartyAllocationWithOrdinaryCoins() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -116,9 +218,7 @@ class CombatExperienceNativeTest {
assertEquals(17, death.getDroppedExp(), "Fantasy rewards never replace native Minecraft XP");
assertTrue(a.messages.stream().anyMatch(message -> message.contains("100") && message.contains("XP")));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Level 2")));
assertTrue(mockingDetails(a.world).getInvocations().stream().anyMatch(invocation ->
invocation.getMethod().getName().equals("spawnParticle")
&& java.util.Arrays.asList(invocation.getArguments()).contains(12)));
assertTrue(a.particleCalls.stream().anyMatch(call -> java.util.Arrays.asList(call).contains(12)));
assertFalse(a.sounds.isEmpty());
a.core.inventory.getInventory().setSelectedSlot(0);
@@ -231,10 +331,11 @@ class CombatExperienceNativeTest {
var original = new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), original.id());
var attacker = mock(org.bukkit.entity.Player.class);
when(attacker.getUniqueId()).thenReturn(active.player());
when(attacker.getUniqueId()).thenReturn(active.player()); when(attacker.isOnline()).thenReturn(true);
when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active));
when(sessions.activeCharacter(active.character())).thenReturn(java.util.Optional.of(active));
when(sessions.current(active)).thenReturn(true);
when(sessions.acknowledgeProgress(eq(active), any(), any())).thenReturn(true);
when(storage.find(original.id())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(java.util.Optional.of(original)));
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.CombatAward>();
failed.completeExceptionally(new java.io.IOException("simulated write failure"));
@@ -259,13 +360,15 @@ class CombatExperienceNativeTest {
service.damage(new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, damageSource, 1));
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
verify(storage, times(1)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
assertEquals(1, failures.size());
assertEquals(1, failures.size()); verify(attacker, never()).sendMessage(anyString());
service.configure(service.rules().withHardPool(java.math.BigInteger.valueOf(25)));
service.retryPending();
verify(storage, times(2)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
verify(attacker, times(1)).sendMessage(contains("character coins"));
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
service.retryPending();
verify(storage, times(2)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
verify(attacker, times(1)).sendMessage(contains("character coins"));
service.close();
}
@@ -76,7 +76,7 @@ class DungeonNativeTest {
actor.core.location.setX(actor.core.location.getBlockX()+4.2);
command(f,actor,"dungeon","room","create","corner","3","4","3","Boss");var bossRoom=dungeons.find(dungeon.id()).orElseThrow().rooms().getLast();
command(f,actor,"dungeon","room","reward","set","30","5");command(f,actor,"dungeon","room","boss","mob","vindicator","Keeper");
actor.particleCalls.clear();pulse(f);assertFalse(actor.particleCalls.isEmpty());assertTrue(actor.particleCalls.size()<=1024);assertTrue(actor.particleCalls.stream().allMatch(call->call[0]==Particle.DUST));var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);doReturn(true).when(outsider.player).hasPermission("fantasystick.admin");command(f,outsider,"dungeon","edit","start",dungeon.id().toString());assertTrue(outsider.messages.getLast().contains("editing lease"));assertTrue(outsider.particleCalls.isEmpty());
actor.particleCalls.clear();pulse(f);assertFalse(actor.particleCalls.isEmpty());assertTrue(actor.particleCalls.size()<=1024);assertTrue(actor.particleCalls.stream().allMatch(call->call[0]==Particle.DUST));var particlePreferences=(games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"feedbackPreferences");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,false).toCompletableFuture().get(5,TimeUnit.SECONDS)));actor.particleCalls.clear();pulse(f);assertTrue(actor.particleCalls.isEmpty(),"All-particle opt-out suppresses dungeon-editor boundaries");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,true).toCompletableFuture().get(5,TimeUnit.SECONDS)));var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);doReturn(true).when(outsider.player).hasPermission("fantasystick.admin");command(f,outsider,"dungeon","edit","start",dungeon.id().toString());assertTrue(outsider.messages.getLast().contains("editing lease"));assertTrue(outsider.particleCalls.isEmpty());
command(f,actor,"dungeon","edit","done");actor.particleCalls.clear();pulse(f);assertTrue(actor.particleCalls.isEmpty());command(f,actor,"dungeon","enable",dungeon.id().toString());command(f,actor,"dungeon","open",dungeon.id().toString());
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");assertTrue(adventures.snapshot().catalog().sites().contains(dungeon.id()));assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(80),BigInteger.valueOf(15)),adventures.reward(adventures.findSite(dungeon.id()).orElseThrow()));
f.activate(actor);var questController=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");questController.open(actor.player);drain(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));questController.click(click);drain(f);assertEquals(dungeon.id(),adventures.active().getFirst().site());var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);for(var member:List.of(partyA,partyB)){member.core.location.setWorld(actor.world);member.core.location.setX(100);f.players.put(member.core.id,member.player);doReturn(java.util.Set.of()).when(member.player).getActivePotionEffects();doReturn(true).when(member.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());f.activate(member);}var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,TimeUnit.SECONDS).status());
@@ -633,6 +633,110 @@ class EquipmentSessionEventsNativeTest {
}
}
@Test void crossWorldDefeatCloseRebindsProvedTailDeliveryWithOrWithoutArmor() throws Exception {
for (boolean armored : new boolean[]{false, true}) try (var f = new Fixture()) {
var a = f.actor;
var overworld = org.mockito.Mockito.mock(org.bukkit.World.class);
var end = org.mockito.Mockito.mock(org.bukkit.World.class);
org.mockito.Mockito.when(overworld.getUID()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(end.getUID()).thenReturn(java.util.UUID.randomUUID());
a.location = new org.bukkit.Location(overworld, 12, 70, -8, 45, 10);
if (!armored) a.inventory.setArmorContents(new ItemStack[4]);
var entry = a.location.clone(); f.core.activate(a);
a.location = new org.bukkit.Location(end, 80, 64, 40);
var closingView = a.view;
a.teleport = () -> {
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryCloseEvent(closingView));
a.view = null;
};
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.OUT_OF_WORLD));
f.core.tick();
assertEquals(entry, a.location);
assertEquals(Material.WRITTEN_BOOK, a.inventory.getItemInOffHand().getType());
assertTrue(f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS)
.orElseThrow().dead());
assertNull(f.core.state(a).pending(), "Successful Tail delivery releases the completed receipt");
var deadSheet = new games.dmg.fantasystick.items.CharacterSheets().create(a.record);
a.inventory.setItemInOffHand(deadSheet); f.core.sessions.enter(a.player);
f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS); f.core.tick();
assertEquals("UNAVAILABLE_CHARACTER", f.core.notices.getLast().code(),
"A completed defeat has no false persistence blocker and cannot reactivate");
}
}
@Test void exactRetainedSheetUseCompletesOnlyNeverStartedSameLifetimeTailConversion() throws Exception {
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a);
var retained = a.inventory.getItemInOffHand().clone();
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
var original = f.core.next();
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
assertNull(f.core.state(a).pending(), "Ordinary restoration must be acknowledged before retry");
f.core.sessions.enter(a.player);
assertEquals(Material.WRITTEN_BOOK, a.inventory.getItemInOffHand().getType());
var delivered = a.inventory.getItemInOffHand().clone();
original.run(); original.run(); f.core.sessions.enter(a.player);
assertEquals(delivered, a.inventory.getItemInOffHand());
assertEquals(1, a.teleports, "Retry never replays defeat restoration or return teleport");
a.inventory.setItemInOffHand(retained); f.core.sessions.enter(a.player);
f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS); f.core.tick();
assertEquals("UNAVAILABLE_CHARACTER", f.core.notices.getLast().code());
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType(),
"The dead sheet cannot activate or create a second Tail after release");
}
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a);
var copied = a.inventory.getItemInOffHand().clone();
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
var original = f.core.next();
a.inventory.setItemInOffHand(copied);
f.core.sessions.enter(a.player);
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
assertTrue(f.core.notices.getLast().message().contains("inspection"));
original.run(); original.run();
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType(),
"An apparently identical replacement sheet is not process-local custody proof");
assertEquals(1, f.core.failures.size());
f.core.expectedFailure = IllegalStateException.class;
}
}
@Test void rejectedOriginalTailCallbackRetainsNarrowSameLifetimeRetryAuthority() throws Exception {
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
var writes = new java.util.concurrent.atomic.AtomicInteger();
try (var f = new Fixture((source, target) -> {
if (writes.incrementAndGet() == 2) {
entered.countDown();
try { if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("Gate timeout"); }
catch (InterruptedException interrupted) { throw new java.io.IOException(interrupted); }
}
EquipmentSessionsNativeTest.move(source, target);
})) {
var a = f.actor; f.core.activate(a);
try {
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
assertNull(f.core.state(a).pending());
f.core.rejectSchedule = true;
} finally { release.countDown(); }
f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS);
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
assertEquals(1, f.core.failures.size()); f.core.expectedFailure = IllegalStateException.class;
f.core.rejectSchedule = false;
f.core.sessions.enter(a.player);
assertEquals(Material.WRITTEN_BOOK, a.inventory.getItemInOffHand().getType());
assertNull(f.core.state(a).pending());
assertEquals(1, a.teleports);
}
}
@Test void everyRegisteredDamageTypeUsesTheSameDeathBoundaryButOrdinaryAndPriorVetoesStayUntouched() throws Exception {
int causes = 0;
for (var type : org.bukkit.Registry.DAMAGE_TYPE) {
@@ -717,6 +821,11 @@ class EquipmentSessionEventsNativeTest {
assertEquals(1, f.core.failures.size()); assertInstanceOf(IllegalStateException.class, f.core.failures.getFirst().cause());
f.core.expectedFailure = IllegalStateException.class;
a.online = true; f.core.sessions.arrive(a.player); f.core.sessions.recover(a.player);
if (scenario == 3) {
f.core.sessions.enter(a.player);
assertEquals("DEFEAT_INSPECTION_REQUIRED", f.core.notices.getLast().code(),
"Reconnect loses same-lifetime Tail retry authority");
}
assertArrayEquals(before, a.snapshot()); assertTrue(f.core.tasks.isEmpty());
}
}
@@ -762,7 +871,7 @@ class EquipmentSessionEventsNativeTest {
assertTrue(event.isCancelled()); assertArrayEquals(before, a.snapshot());
assertTrue(a.marker().endsWith(exception ? "|RESTORING" : "|PACKED"));
assertNotNull(f.core.state(a).pending()); assertEquals(a.normalMaximum, a.health);
var callback = f.core.next(); callback.run(); callback.run();
assertTrue(f.core.tasks.isEmpty(), "Unrestored equipment never queues a Tail write");
assertArrayEquals(before, a.snapshot()); assertNotNull(f.core.state(a).pending());
assertTrue(f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().dead());
assertEquals(exception ? 2 : 1, f.core.failures.size()); f.core.expectedFailure = IllegalStateException.class;
@@ -790,6 +899,10 @@ class EquipmentSessionEventsNativeTest {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
assertNull(f.core.state(a).pending(), "Gear receipt cleanup acknowledged first");
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
f.core.sessions.enter(a.player);
assertEquals("DEFEAT_PENDING", f.core.notices.getLast().code());
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType(),
"Pending death persistence grants neither activation nor a Tail");
competingRead.run();
assertEquals("CHARACTER_BUSY", f.core.notices.getLast().code());
assertTrue(f.core.sessions.active(copy.id).isEmpty()); assertTrue(f.core.tasks.isEmpty());
@@ -824,7 +937,7 @@ class EquipmentSessionEventsNativeTest {
assertEquals(Material.DIAMOND_SWORD, a.inventory.getItem(0).getType());
assertEquals(sheet, a.inventory.getItemInOffHand());
} finally { release.countDown(); }
if (route == 0) f.core.tick();
if (route == 0) assertTrue(f.core.tasks.isEmpty(), "Quit invalidates delivery before any Tail write");
f.core.sessions.close(); f.core.sessions.drained().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
var dead = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(a.record.id()).orElseThrow();
assertTrue(dead.dead()); assertEquals(sheet, a.inventory.getItemInOffHand()); assertEquals(2, writes.get());
@@ -932,6 +1045,11 @@ class EquipmentSessionEventsNativeTest {
callback.run(); a.offhandMutation = () -> {}; a.beforeOffhandMutation = () -> {};
assertEquals(afterWrite ? Material.WRITTEN_BOOK : Material.PAPER, a.inventory.getItemInOffHand().getType());
var current = a.snapshot(); callback.run(); f.core.sessions.recover(a.player);
if (!afterWrite) {
f.core.sessions.enter(a.player);
assertEquals("DEFEAT_INSPECTION_REQUIRED", f.core.notices.getLast().code(),
"A possible native write never authorizes retained-sheet retry");
}
assertArrayEquals(current, a.snapshot()); assertTrue(f.core.tasks.isEmpty());
assertEquals(2, f.core.failures.size()); f.core.expectedFailure = IllegalStateException.class;
assertTrue(f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().dead());
@@ -942,7 +1060,8 @@ class EquipmentSessionEventsNativeTest {
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a); var control = a.inventory.getItem(0).clone();
a.inventory.setItem(0, new ItemStack(Material.EMERALD));
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC)); f.core.tick();
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
assertTrue(f.core.tasks.isEmpty(), "Failed restoration queues no Tail write");
assertNotNull(f.core.state(a).pending()); assertTrue(a.marker().endsWith("|PACKED"));
var dead = f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
a.inventory.setItem(20, a.inventory.getItem(0)); a.inventory.setItem(0, control);
@@ -64,16 +64,21 @@ class FantasyHealthNativeTest {
}
}
@Test void activeBlueMistAndOneRedDefeatBurstUseWorldLocationsAndStopWithTheSession() throws Exception {
@Test void activeBlueMistDefaultsOffWhileOptInAndOneRedDefeatBurstRespectTheirOwnLifetimes() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data); ordinary.core.inventory.setItemInOffHand(null);
f.players.put(a.core.id, a.player); f.players.put(ordinary.core.id, ordinary.player);
var entry = a.player.getLocation(); f.activate(a);
f.pulses.forEach(Runnable::run);
assertTrue(dust(a, org.bukkit.Color.BLUE) > 0, "Active fantasy mode must have visible public blue particles");
assertEquals(0, dust(ordinary, org.bukkit.Color.BLUE));
f.pulses.getLast().run();
assertEquals(0, receivedDust(a, org.bukkit.Color.BLUE), "Active-character auras default off per viewer");
assertEquals(0, receivedDust(ordinary, org.bukkit.Color.BLUE));
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"players", "on"}); f.tick();
f.pulses.getLast().run();
assertTrue(receivedDust(a, org.bukkit.Color.BLUE) > 0, "An opted-in active viewer sees the current blue aura");
assertEquals(0, receivedDust(ordinary, org.bukkit.Color.BLUE));
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"players", "off"}); f.tick();
a.core.location.add(20, 0, 20);
var defeatLocation = a.player.getLocation().add(0, 1, 0);
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
@@ -82,19 +87,41 @@ class FantasyHealthNativeTest {
net.kyori.adventure.text.Component.empty(), true);
f.dispatcher.accept(death); assertTrue(death.isCancelled());
PartyBookNativeTest.drain(f);
assertEquals(1, dust(a, org.bukkit.Color.RED));
assertTrue(org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream().anyMatch(call ->
call.getMethod().getName().equals("spawnParticle") && java.util.Arrays.asList(call.getArguments()).contains(defeatLocation)));
assertEquals(1, receivedDust(a, org.bukkit.Color.RED), "Aura opt-out must not hide the defeat burst");
assertTrue(a.particleCalls.stream().anyMatch(call -> java.util.Arrays.asList(call).contains(defeatLocation)));
assertEquals(entry, a.player.getLocation()); assertEquals(28, a.player.getHealth());
var tail = a.core.inventory.getItemInOffHand().clone();
assertEquals(org.bukkit.Material.WRITTEN_BOOK, tail.getType()); assertEquals(1, tail.getAmount());
long blue = dust(a, org.bukkit.Color.BLUE);
long blue = receivedDust(a, org.bukkit.Color.BLUE);
f.pulses.forEach(Runnable::run); f.dispatcher.accept(death);
assertEquals(blue, dust(a, org.bukkit.Color.BLUE)); assertEquals(1, dust(a, org.bukkit.Color.RED));
assertEquals(blue, receivedDust(a, org.bukkit.Color.BLUE)); assertEquals(1, receivedDust(a, org.bukkit.Color.RED));
assertEquals(tail, a.core.inventory.getItemInOffHand());
f.plugin.setEnabled(false); f.awaitWorkers();
f.pulses.forEach(Runnable::run);
assertEquals(blue, dust(a, org.bukkit.Color.BLUE), "Retired timers cannot emit fantasy effects");
assertEquals(blue, receivedDust(a, org.bukkit.Color.BLUE), "Retired timers cannot emit fantasy effects");
}
}
@Test void allParticleOptOutSuppressesDefeatBurstOnlyForThatViewer() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var defeated = new FantasyStickLifecycleNativeTest.Actor(data);
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(defeated.world);
f.players.put(defeated.core.id, defeated.player); f.players.put(viewer.core.id, viewer.player);
f.activate(defeated);
f.particlesCommand.execute(defeated.player, "fantasyparticles", new String[]{"all", "off"}); f.tick();
defeated.particleCalls.clear(); viewer.particleCalls.clear(); defeated.messages.clear();
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
var death = new org.bukkit.event.entity.PlayerDeathEvent(defeated.player, source,
new java.util.ArrayList<>(), 0, net.kyori.adventure.text.Component.empty(), true);
f.dispatcher.accept(death); PartyBookNativeTest.drain(f);
assertTrue(death.isCancelled());
assertEquals(0, receivedDust(defeated, org.bukkit.Color.RED));
assertEquals(1, receivedDust(viewer, org.bukkit.Color.RED));
assertTrue(defeated.messages.stream().anyMatch(message -> message.toLowerCase(java.util.Locale.ROOT).contains("defeat")));
assertEquals(org.bukkit.Material.WRITTEN_BOOK, defeated.core.inventory.getItemInOffHand().getType());
}
}
@@ -347,10 +374,10 @@ class FantasyHealthNativeTest {
}
}
private static long dust(FantasyStickLifecycleNativeTest.Actor a, org.bukkit.Color color) {
return org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream().filter(call ->
call.getMethod().getName().equals("spawnParticle") && java.util.Arrays.stream(call.getArguments())
.anyMatch(argument -> argument instanceof org.bukkit.Particle.DustOptions dust && dust.getColor().equals(color))).count();
private static long receivedDust(FantasyStickLifecycleNativeTest.Actor a, org.bukkit.Color color) {
return a.particleCalls.stream().filter(call -> java.util.Arrays.stream(call)
.anyMatch(argument -> argument instanceof org.bukkit.Particle.DustOptions dust
&& dust.getColor().equals(color))).count();
}
private static net.minecraft.world.entity.ai.attributes.AttributeModifier modifier(String name, double amount,
@@ -0,0 +1,307 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.List;
import org.bukkit.Color;
import org.bukkit.Particle;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered account command through the real recurring active-session aura pulse. */
class FantasyParticlesNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredPlayersRouteDefaultsOffPersistsAndRejectsInvalidSendersAndArguments() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
assertNotNull(fixture.particlesCommand);
var player = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(player.core.id, player.player);
assertTrue(fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"players"}));
assertEquals("Active-character aura particles are off.", player.messages.getLast());
assertTrue(fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"all"}));
assertEquals("All Fantasy Stick particles are on.", player.messages.getLast());
assertEquals(List.of("all", "players"), fixture.particlesCommand.tabComplete(
player.player, "fantasyparticles", new String[]{""}));
assertEquals(List.of("off", "on"), fixture.particlesCommand.tabComplete(
player.player, "fantasyparticles", new String[]{"players", ""}));
assertEquals(List.of("off"), fixture.particlesCommand.tabComplete(
player.player, "fantasyparticles", new String[]{"players", "of"}));
assertEquals(List.of("on"), fixture.particlesCommand.tabComplete(
player.player, "fantasyparticles", new String[]{"players", "on"}));
assertFalse(java.nio.file.Files.exists(data.resolve("player-preferences.yml")),
"Status and completion perform no persistence write");
assertTrue(player.particleCalls.isEmpty(), "Status and completion emit no particles");
int before = player.messages.size();
assertTrue(fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"players", "on"}));
assertEquals(before, player.messages.size(), "Persistence must complete before acknowledgement");
fixture.tick();
assertEquals("Active-character aura particles are now on.", player.messages.getLast());
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"all", "off"}); fixture.tick();
assertEquals("All Fantasy Stick particles are now off.", player.messages.getLast());
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are on (effectively off while all particles are off).",
player.messages.getLast());
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"all", "on"}); fixture.tick();
assertEquals("All Fantasy Stick particles are now on.", player.messages.getLast());
var console = new EquipmentAdminCommandNativeTest.Sender();
assertTrue(fixture.particlesCommand.execute(console.port, "fantasyparticles",
new String[]{"players", "off"}));
assertTrue(console.messages.getLast().contains("player-account"));
assertTrue(fixture.particlesCommand.tabComplete(console.port, "fantasyparticles",
new String[]{""}).isEmpty());
for (String[] invalid : List.of(new String[]{}, new String[]{"unknown"},
new String[]{"players", "ON"}, new String[]{"all", "ON"},
new String[]{"players", "on", "extra"}, new String[]{"all", "off", "extra"})) {
assertFalse(fixture.particlesCommand.getExecutor().onCommand(player.player,
fixture.particlesCommand, "fantasyparticles", invalid));
}
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"all", "off"}); fixture.tick();
var other = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(other.core.id, other.player);
var playerSheet = player.core.inventory.getItemInOffHand();
player.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand());
other.core.inventory.setItemInOffHand(playerSheet);
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are on (effectively off while all particles are off).",
player.messages.getLast(), "The preference stays with the account after character-sheet switching");
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"all"});
assertEquals("All Fantasy Stick particles are off.", player.messages.getLast());
fixture.particlesCommand.execute(other.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are off.", other.messages.getLast(),
"A transferred character does not transfer its prior holder's preference");
fixture.particlesCommand.execute(other.player, "fantasyparticles", new String[]{"all"});
assertEquals("All Fantasy Stick particles are on.", other.messages.getLast(),
"A transferred character does not transfer its prior holder's all-particle preference");
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are on (effectively off while all particles are off).",
player.messages.getLast());
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"all"});
assertEquals("All Fantasy Stick particles are off.", player.messages.getLast());
}
}
@Test void reversedCompetingCommandCallbacksCannotPublishStaleAuraState() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(player.core.id, player.player);
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"players", "on"});
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"players", "off"});
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.setPlayerAuras(java.util.UUID.randomUUID(), false).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
var callbacks = new java.util.ArrayList<Runnable>();
fixture.tasks.drainTo(callbacks);
assertEquals(2, callbacks.size());
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
assertFalse(preferences.playerAurasEnabled(player.core.id));
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are off.", player.messages.getLast());
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"all", "off"});
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"all", "on"});
preferences.setAllParticles(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
callbacks.clear(); fixture.tasks.drainTo(callbacks);
assertEquals(2, callbacks.size());
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
assertTrue(preferences.allParticlesEnabled(player.core.id));
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"all"});
assertEquals("All Fantasy Stick particles are on.", player.messages.getLast());
}
}
@Test void failedAuraPreferenceSaveKeepsPriorEffectiveStateAndReportsRefusal() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(player.core.id, player.player);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"players", "on"});
fixture.tick();
assertTrue(player.messages.getLast().contains("could not be saved"));
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are off.", player.messages.getLast());
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"all", "off"});
fixture.tick();
assertTrue(player.messages.getLast().contains("could not be saved"));
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"all"});
assertEquals("All Fantasy Stick particles are on.", player.messages.getLast());
}
}
@Test void allPreferenceFiltersSharedWorldAndExplicitDeliveryWithoutExpandingNativeBounds() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var enabled = new FantasyStickLifecycleNativeTest.Actor(data); enabled.accountName = "Enabled";
var enabledPeer = new FantasyStickLifecycleNativeTest.Actor(data); enabledPeer.accountName = "EnabledPeer";
var disabled = new FantasyStickLifecycleNativeTest.Actor(data); disabled.accountName = "Disabled";
var far = new FantasyStickLifecycleNativeTest.Actor(data); far.accountName = "Far";
var crossWorld = new FantasyStickLifecycleNativeTest.Actor(data); crossWorld.accountName = "CrossWorld";
enabledPeer.core.location.setWorld(enabled.world);
disabled.core.location.setWorld(enabled.world);
far.core.location.setWorld(enabled.world); far.core.location.add(40, 0, 0);
for (var actor : List.of(enabled, enabledPeer, disabled, far, crossWorld)) {
actor.core.inventory.setItemInOffHand(null); fixture.players.put(actor.core.id, actor.player);
}
fixture.particlesCommand.execute(disabled.player, "fantasyparticles", new String[]{"all", "off"});
fixture.tick(); clear(enabled, enabledPeer, disabled, far, crossWorld);
var delivery = (games.dmg.fantasystick.presentation.ParticleDelivery)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "particleDelivery");
var origin = enabled.player.getLocation();
var dust = new Particle.DustOptions(Color.RED, 1.5f);
delivery.world(Particle.DUST, origin, 7, 0.1, 0.2, 0.3, 0.04, dust);
assertEquals(1, enabled.particleCalls.size());
assertEquals(1, enabledPeer.particleCalls.size());
var expected = new Object[]{Particle.DUST, origin, 7, 0.1, 0.2, 0.3, 0.04, dust};
assertArrayEquals(expected, enabled.particleCalls.getFirst());
assertArrayEquals(expected, enabledPeer.particleCalls.getFirst());
assertTrue(disabled.particleCalls.isEmpty());
assertTrue(far.particleCalls.isEmpty());
assertTrue(crossWorld.particleCalls.isEmpty());
org.mockito.Mockito.verify(enabled.world, org.mockito.Mockito.never()).spawnParticle(
org.mockito.ArgumentMatchers.any(Particle.class), org.mockito.ArgumentMatchers.any(org.bukkit.Location.class),
org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.any());
delivery.viewer(disabled.player, Particle.ANGRY_VILLAGER, origin, 8, 0.3, 0.4, 0.3, 0);
delivery.viewer(crossWorld.player, Particle.ANGRY_VILLAGER, origin, 8, 0.3, 0.4, 0.3, 0);
delivery.viewer(enabled.player, Particle.ANGRY_VILLAGER, origin, 8, 0.3, 0.4, 0.3, 0);
assertTrue(disabled.particleCalls.isEmpty());
assertTrue(crossWorld.particleCalls.isEmpty());
assertEquals(2, enabled.particleCalls.size());
assertEquals(1, enabledPeer.particleCalls.size(), "Targeted delivery is not duplicated to another viewer");
assertEquals(Particle.ANGRY_VILLAGER, enabled.particleCalls.getLast()[0]);
}
}
@Test void recurringAuraIsDefaultOffAndDeliveredOnlyToOptedInNativeViewers() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var first = new FantasyStickLifecycleNativeTest.Actor(data); first.accountName = "First";
var second = new FantasyStickLifecycleNativeTest.Actor(data); second.accountName = "Second";
var enabled = new FantasyStickLifecycleNativeTest.Actor(data); enabled.accountName = "Enabled";
var disabled = new FantasyStickLifecycleNativeTest.Actor(data); disabled.accountName = "Disabled";
var untracked = new FantasyStickLifecycleNativeTest.Actor(data); untracked.accountName = "Untracked";
var hidden = new FantasyStickLifecycleNativeTest.Actor(data); hidden.accountName = "Hidden"; hidden.visible = false;
var crossWorld = new FantasyStickLifecycleNativeTest.Actor(data); crossWorld.accountName = "CrossWorld";
second.core.location.setWorld(first.world); second.core.location.add(5, 0, 0);
enabled.core.location.setWorld(first.world);
disabled.core.location.setWorld(first.world);
untracked.core.location.setWorld(first.world);
hidden.core.location.setWorld(first.world);
enabled.core.inventory.setItemInOffHand(null);
disabled.core.inventory.setItemInOffHand(null);
untracked.core.inventory.setItemInOffHand(null);
hidden.core.inventory.setItemInOffHand(null);
crossWorld.core.inventory.setItemInOffHand(null);
for (var actor : List.of(first, second, enabled, disabled, untracked, hidden, crossWorld))
fixture.players.put(actor.core.id, actor.player);
first.tracked.add(enabled.player); first.tracked.add(disabled.player); first.tracked.add(hidden.player);
first.tracked.add(crossWorld.player);
second.tracked.add(enabled.player); second.tracked.add(disabled.player); second.tracked.add(hidden.player);
second.tracked.add(first.player);
fixture.activate(first); fixture.activate(second);
var firstInventory = first.core.snapshot(); var secondInventory = second.core.snapshot();
String firstMarker = first.core.marker(); String secondMarker = second.core.marker();
clear(first, second, enabled, disabled, untracked, hidden, crossWorld);
auraPulse(fixture);
assertTrue(first.particleCalls.isEmpty());
assertTrue(second.particleCalls.isEmpty());
assertTrue(enabled.particleCalls.isEmpty());
assertTrue(disabled.particleCalls.isEmpty());
assertTrue(hidden.particleCalls.isEmpty());
org.mockito.Mockito.verify(first.world, org.mockito.Mockito.never()).spawnParticle(
org.mockito.ArgumentMatchers.eq(Particle.DUST), org.mockito.ArgumentMatchers.any(org.bukkit.Location.class),
org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.any(Particle.DustOptions.class));
optIn(fixture, enabled);
optIn(fixture, first);
optIn(fixture, untracked);
optIn(fixture, hidden);
optIn(fixture, crossWorld);
clear(first, second, enabled, disabled, untracked, hidden, crossWorld);
auraPulse(fixture);
assertEquals(2, blueAuraCalls(enabled).size(), "An ordinary opted-in viewer sees both tracked sources");
assertEquals(2, blueAuraCalls(first).size(), "An opted-in active viewer sees self and a tracked peer");
assertTrue(disabled.particleCalls.isEmpty(), "Default-off viewers receive no substitute packet");
assertTrue(untracked.particleCalls.isEmpty(), "Opt-in does not expand native tracking");
assertTrue(hidden.particleCalls.isEmpty(), "Opt-in preserves native player visibility");
assertTrue(crossWorld.particleCalls.isEmpty(), "Opt-in does not cross world boundaries");
assertEquals(java.util.Set.of(first.player.getLocation().add(0, 1, 0),
second.player.getLocation().add(0, 1, 0)), blueAuraCalls(enabled).stream()
.map(call -> (org.bukkit.Location) call[1]).collect(java.util.stream.Collectors.toSet()));
for (Object[] call : blueAuraCalls(enabled)) {
assertEquals(3, call[2]);
assertEquals(0.3, (Double) call[3]); assertEquals(0.4, (Double) call[4]);
assertEquals(0.3, (Double) call[5]); assertEquals(0.0, (Double) call[6]);
}
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
var allOff = preferences.setAllParticles(enabled.core.id, false).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
assertTrue(preferences.publish(allOff));
clear(enabled); auraPulse(fixture);
assertTrue(enabled.particleCalls.isEmpty(), "All-particle off takes precedence over saved aura opt-in");
assertTrue(preferences.playerAurasEnabled(enabled.core.id), "Precedence does not overwrite aura preference");
var allOn = preferences.setAllParticles(enabled.core.id, true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
assertTrue(preferences.publish(allOn));
clear(enabled); auraPulse(fixture);
assertEquals(2, blueAuraCalls(enabled).size(), "Re-enabling all restores the saved aura preference");
assertArrayEquals(firstInventory, first.core.snapshot()); assertArrayEquals(secondInventory, second.core.snapshot());
assertEquals(firstMarker, first.core.marker()); assertEquals(secondMarker, second.core.marker());
}
}
private static void optIn(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
fixture.particlesCommand.execute(actor.player, "fantasyparticles", new String[]{"players", "on"});
fixture.tick();
}
private static void auraPulse(FantasyStickLifecycleNativeTest.Fixture fixture) {
fixture.pulses.getLast().run();
}
private static List<Object[]> blueAuraCalls(FantasyStickLifecycleNativeTest.Actor actor) {
return actor.particleCalls.stream().filter(call -> call[0] == Particle.DUST && call[2].equals(3)
&& java.util.Arrays.stream(call).anyMatch(value -> value instanceof Particle.DustOptions dust
&& dust.getColor().equals(Color.BLUE))).toList();
}
private static void clear(FantasyStickLifecycleNativeTest.Actor... actors) {
for (var actor : actors) { actor.particles.clear(); actor.particleCalls.clear(); }
}
}
@@ -544,6 +544,22 @@ class FantasyStickLifecycleNativeTest {
}
}
@Test void allParticleOptOutSuppressesInvalidControlParticlesWithoutChangingRefusalOrInventory() throws Exception {
try (var f = new Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new Actor(data); f.players.put(actor.core.id, actor.player);
actor.core.inventory.setItem(4, new org.bukkit.inventory.ItemStack(org.bukkit.Material.SHULKER_BOX));
f.particlesCommand.execute(actor.player, "fantasyparticles", new String[]{"all", "off"}); f.tick();
actor.messages.clear(); actor.particles.clear(); actor.particleCalls.clear();
var before = actor.core.snapshot(); f.changed(actor); f.tick(); f.tick();
assertArrayEquals(before, actor.core.snapshot()); assertNull(actor.core.marker());
assertEquals(1, actor.messages.size()); assertTrue(actor.messages.getFirst().contains("vanilla shulker"));
assertTrue(actor.particleCalls.isEmpty(), "Invalid-control feedback sends no particle to an opted-out viewer");
assertTrue(f.receipts().find(actor.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS).isEmpty());
}
}
@Test void startupArrivalIsSilentForOrdinaryPlayersAndReceiptFirstForSheetHolders() throws Exception {
try (var f = new Fixture(data)) {
var ordinary = new Actor(data); ordinary.core.inventory.setItemInOffHand(null);
@@ -1129,6 +1145,7 @@ class FantasyStickLifecycleNativeTest {
final Map<java.util.UUID, org.bukkit.entity.Player> players = new HashMap<>();
org.bukkit.command.PluginCommand command;
org.bukkit.command.PluginCommand feedbackCommand;
org.bukkit.command.PluginCommand particlesCommand;
int playerLookups;
String failRegistrationAt;
boolean failRemoval;
@@ -1211,7 +1228,11 @@ class FantasyStickLifecycleNativeTest {
case "getWorld" -> players.values().stream().map(org.bukkit.entity.Player::getWorld)
.filter(world -> java.util.Objects.equals(world.getUID(), args[0])).findFirst().orElse(null);
case "getPlayer" -> { assertSame(main, Thread.currentThread()); playerLookups++; yield players.get(args[0]); }
case "getPluginCommand" -> "fantasyfeedback".equals(args[0]) ? feedbackCommand : command;
case "getPluginCommand" -> switch ((String) args[0]) {
case "fantasyfeedback" -> feedbackCommand;
case "fantasyparticles" -> particlesCommand;
default -> command;
};
case "createInventory" -> args[1] instanceof Integer size
? new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], size, (net.kyori.adventure.text.Component) args[2])
: new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], (org.bukkit.event.inventory.InventoryType) args[1], (net.kyori.adventure.text.Component) args[2]);
@@ -1251,6 +1272,13 @@ class FantasyStickLifecycleNativeTest {
feedbackCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
feedbackCommand.setUsage((String) feedbackDefinition.get("usage"));
}
var particlesDefinition = plugin.getDescription().getCommands().get("fantasyparticles");
if (particlesDefinition != null) {
particlesCommand = constructor.newInstance("fantasyparticles", plugin);
particlesCommand.setPermission((String) particlesDefinition.get("permission"));
particlesCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
particlesCommand.setUsage((String) particlesDefinition.get("usage"));
}
}
<T> List<T> packets(org.bukkit.entity.Player viewer, Class<T> type) {
@@ -1352,6 +1380,9 @@ class FantasyStickLifecycleNativeTest {
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
boolean healthScaled;
boolean sprinting;
boolean allowed = true;
boolean visible = true;
String accountName = "Account";
double healthScale = 20;
double absorption;
Runnable scaleMutation = () -> {};
@@ -1381,11 +1412,12 @@ class FantasyStickLifecycleNativeTest {
case "equals" -> call.getMock() == args[0];
case "getHandle" -> nativePlayer;
case "getEntityId" -> 42;
case "getName" -> "Account";
case "getName" -> accountName;
case "hasPermission" -> allowed;
case "getWorld" -> core.location.getWorld();
case "getHeight" -> 1.8;
case "getTrackedBy" -> tracked;
case "canSee" -> true;
case "canSee" -> visible;
case "getAttribute" -> {
assertEquals(org.bukkit.attribute.Attribute.MAX_HEALTH, args[0]);
yield new org.bukkit.craftbukkit.attribute.CraftAttributeInstance(maximumHealth, org.bukkit.attribute.Attribute.MAX_HEALTH);
@@ -49,7 +49,15 @@ class FireballNativeTest {
a.core.inventory.getInventory().setSelectedSlot(2);
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(a.world);
f.players.put(viewer.core.id, viewer.player);
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(f);
a.particleCalls.clear(); viewer.particleCalls.clear();
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertTrue(a.particleCalls.isEmpty(), "The opted-out caster receives no Fireball particles");
assertTrue(viewer.particleCalls.stream().anyMatch(call -> call[0] == org.bukkit.Particle.FLAME
&& call[2].equals(8)), "An enabled nearby viewer retains the Fireball burst");
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand()); assertEquals(1, launches.get());
org.mockito.Mockito.verify(projectile).setVisualFire(true);
org.mockito.Mockito.verify(projectile).setPickupStatus(org.bukkit.entity.AbstractArrow.PickupStatus.DISALLOWED);
@@ -77,7 +77,15 @@ class FrostNovaNativeTest {
assertTrue(text.contains("FrostNova")); assertTrue(text.contains("INT16")); assertTrue(text.contains("DEX10"));
assertFalse(text.contains("Cost:")); assertTrue(text.contains("5")); assertTrue(text.contains("SlownessII"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "frost_nova")); drain(fixture, 2);
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(caster.world);
fixture.players.put(viewer.core.id, viewer.player);
fixture.particlesCommand.execute(caster.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(fixture);
caster.particleCalls.clear(); viewer.particleCalls.clear();
use(fixture, caster); drain(fixture, 2);
assertTrue(caster.particleCalls.isEmpty(), "The opted-out caster receives no Frost Nova particles");
assertTrue(viewer.particleCalls.stream().anyMatch(call -> call[0] == org.bukkit.Particle.SNOWFLAKE
&& call[2].equals(32)), "An enabled nearby viewer retains the Frost Nova burst");
assertEquals(8, damage.get(enemy.getUniqueId()), 1.0e-9); assertEquals(8, damage.get(opponent.core.id), 1.0e-9);
assertEquals(2, damage.size(), "Duplicate, cancelled, party and ordinary targets cannot add accepted hits");
assertEquals(1, caster.messages.stream().filter("Frost Nova did 8 damage to Zombie."::equals).count());
@@ -52,7 +52,15 @@ class FrostShardNativeTest {
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(a.world);
f.players.put(viewer.core.id, viewer.player);
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(f);
a.particleCalls.clear(); viewer.particleCalls.clear();
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertTrue(a.particleCalls.isEmpty(), "The opted-out caster receives no Frost Shard particles");
assertTrue(viewer.particleCalls.stream().anyMatch(call -> call[0] == org.bukkit.Particle.SNOWFLAKE
&& call[2].equals(8)), "An enabled nearby viewer retains the Frost Shard burst");
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand());
verify(a.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
assertEquals(new org.bukkit.util.Vector(3, 0, 0), velocity.get());
@@ -30,7 +30,7 @@ class MeteorNativeTest {
@Test void characterBookPurchaseEquippedGroundWarningAndNativeImpact()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false);f.players.put(caster.core.id,caster.player);f.activate(caster);var placement=placement(caster,24,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,new UUID(0,2),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{double amount=call.getArgument(0);damaged.add(amount);var source=(DamageSource)call.getArgument(1);f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,enemy,EntityDamageEvent.DamageCause.MAGIC,source,amount));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);verify(caster.world,atLeast(40)).spawnParticle(eq(Particle.DUST),any(Location.class),eq(24),anyDouble(),anyDouble(),anyDouble(),eq(0d),any(Particle.DustOptions.class));verify(caster.world,atLeastOnce()).spawnParticle(eq(Particle.EXPLOSION_EMITTER),any(Location.class),eq(2),anyDouble(),anyDouble(),anyDouble(),anyDouble());verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);f.particlesCommand.execute(caster.player,"fantasyparticles",new String[]{"all","off"});PartyBookNativeTest.drain(f);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Meteor warning or impact particles");assertTrue(viewer.particleCalls.stream().filter(call->call[0]==Particle.DUST&&call[2].equals(24)).count()>=40);assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.EXPLOSION_EMITTER&&call[2].equals(2)));assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.FLAME&&call[2].equals(48)));verify(caster.world,atLeastOnce()).playSound(any(Location.class),eq(Sound.ENTITY_GENERIC_EXPLODE),eq(1f),eq(.7f));verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
}
}
@@ -40,8 +40,11 @@ class SanctuaryNativeTest {
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(fixture,caster);
String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Sanctuary"));assertTrue(text.contains("WIS24"));assertTrue(text.contains("CHR20"));assertTrue(text.contains("CON10"));
PartyBookNativeTest.click(fixture,caster,button(caster,"[Learn]","sanctuary"));drain(fixture,2);
var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);fixture.players.put(viewer.core.id,viewer.player);
fixture.particlesCommand.execute(caster.player,"fantasyparticles",new String[]{"all","off"});PartyBookNativeTest.drain(fixture);caster.particleCalls.clear();viewer.particleCalls.clear();
use(fixture,caster);drain(fixture,2);assertEquals(2L,fixture.delayedTicks.getLast());
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);assertEquals(6,ally.core.health,0);
assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Sanctuary particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==org.bukkit.Particle.END_ROD&&call[2].equals(12)),"An enabled nearby viewer retains Sanctuary visuals");
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(caster.world.getUID()).thenReturn(UUID.randomUUID());when(enemy.getLocation()).thenReturn(new Location(caster.world,8,64,0));
var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();
@@ -35,7 +35,7 @@ class WarBannerNativeTest {
@Test void bookAcquisitionSolidTopPlacementBenefitsPartyAndRewardsUsedDamage()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);resistance.modifiers().add(new AttributeModifier(new NamespacedKey("test","unrelated_knockback"),.2,AttributeModifier.Operation.ADD_NUMBER));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("WarBanner"));assertTrue(text.contains("STR22"));assertTrue(text.contains("CHR22"));assertTrue(text.contains("CON12"));assertTrue(text.contains("25%"));assertTrue(text.contains("50%"));
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);use(f,caster);drain(f,2);assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);f.particlesCommand.execute(caster.player,"fantasyparticles",new String[]{"all","off"});PartyBookNativeTest.drain(f);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no War Banner particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.DUST&&call[2].equals(16)),"An enabled nearby viewer retains War Banner visuals");assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(caster.world,4,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();var hit=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
f.delayed.getLast().run();assertEquals(.2,resistance.value(),1e-9,"Owned cleanup preserves unrelated knockback modifiers");var expired=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1e-9);
}
@@ -285,7 +285,8 @@ class WholeCharacterTransferNativeTest {
}
if (ending == Ending.DEFEAT) {
if (restoration != Restoration.SUCCESS) h.f.expectedFailure = IllegalStateException.class;
h.f.tick(); // Actual asynchronous death acknowledgment / one-use Tail attempt.
if (restoration == Restoration.SUCCESS)
h.f.tick(); // Restoration acknowledgment permits the one-use Tail attempt.
var dead = h.saved(record.id()); assertTrue(dead.dead()); assertProgress(renamed, dead);
assertEquals(0, dead.health().signum());
assertEquals(restoration == Restoration.SUCCESS ? Material.WRITTEN_BOOK : Material.PAPER,
@@ -29,16 +29,31 @@ class DistributionTest {
assertEquals("games.dmg.fantasystick.FantasyStickPlugin", metadata.getMain());
assertNotNull(metadata.getAPIVersion(), "Declare the native API compatibility contract");
assertEquals(java.util.List.of("ProtocolLib"), metadata.getDepend());
assertEquals(java.util.Set.of("fantasystick", "fantasyfeedback"), metadata.getCommands().keySet());
assertEquals(java.util.Set.of("fantasystick", "fantasyfeedback", "fantasyparticles"),
metadata.getCommands().keySet());
assertNull(metadata.getCommands().get("fantasystick").get("permission"),
"Player adventure inspection must reach command-level authorization");
assertNull(metadata.getCommands().get("fantasyfeedback").get("permission"));
assertNull(metadata.getCommands().get("fantasyparticles").get("permission"));
assertEquals("/fantasyfeedback [on|off]", metadata.getCommands().get("fantasyfeedback").get("usage"));
assertEquals("/fantasyparticles <all|players> [on|off]",
metadata.getCommands().get("fantasyparticles").get("usage"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("adventure-site"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("quest-giver"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString()
.contains("character view <online-player>"));
assertEquals(1, metadata.getPermissions().size());
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
var directParticleEmitters = jar.stream().filter(entry -> entry.getName().endsWith(".class"))
.filter(entry -> {
try (var input = jar.getInputStream(entry)) {
return new String(input.readAllBytes(), java.nio.charset.StandardCharsets.ISO_8859_1)
.contains("spawnParticle");
} catch (java.io.IOException failure) { throw new java.io.UncheckedIOException(failure); }
}).map(java.util.zip.ZipEntry::getName).collect(java.util.stream.Collectors.toSet());
assertEquals(java.util.Set.of("games/dmg/fantasystick/presentation/ParticleDelivery.class"),
directParticleEmitters, "Every owned particle producer must route through viewer-aware delivery");
var configuration = jar.getJarEntry("config.yml");
assertNotNull(configuration, "Ship reviewed session defaults for administrators");
String defaults;
@@ -135,6 +135,54 @@ class CharacterStorageTest {
}
}
@Test
void restrictedSpacingAtomicallyDiscardsCoinsWithoutDisablingOtherZeroXpCoinAwards() throws Exception {
var repository = new YamlCharacterRepository(data);
var progression = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.TEN, 30);
var rewards = games.dmg.fantasystick.progression.CombatRewardRules.defaults();
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);
var live = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
base.experience(), base.unspentPoints(), base.health(), base.learned(), base.loadout(), base.passives(),
base.cooldowns(), base.restrictedKills(), false, null, null, BigInteger.valueOf(20), true, java.util.Set.of());
var world = UUID.randomUUID();
var first = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(world,
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
var near = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(world,
java.math.BigDecimal.ONE, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(live).join();
var eligible = storage.awardCombatReward(live.id(), live.revision(), UUID.randomUUID(), BigInteger.TEN,
BigInteger.valueOf(6), progression, rewards, first).join();
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, eligible.status());
assertEquals(BigInteger.valueOf(26), eligible.record().orElseThrow().coins());
var blockedId = UUID.randomUUID();
var blocked = storage.awardCombatReward(live.id(), eligible.record().orElseThrow().revision(), blockedId,
BigInteger.TEN, BigInteger.valueOf(7), progression, rewards, near).join();
assertEquals(CharacterStorage.CombatAwardStatus.SPACING_BLOCKED, blocked.status());
var afterBlocked = blocked.record().orElseThrow();
assertEquals(eligible.record().orElseThrow().experience(), afterBlocked.experience());
assertEquals(BigInteger.valueOf(26), afterBlocked.coins(), "Spacing discards rather than escrows the allocated coin share");
assertEquals(near, afterBlocked.restrictedKills().get(world), "Blocked awards advance the persisted spacing chain");
assertTrue(afterBlocked.rewardReceipts().contains(blockedId));
assertEquals(afterBlocked, storage.awardCombatReward(live.id(), afterBlocked.revision(), blockedId,
BigInteger.TEN, BigInteger.valueOf(7), progression, rewards, near).join().record().orElseThrow());
var zeroXp = storage.awardCombatReward(live.id(), afterBlocked.revision(), UUID.randomUUID(), BigInteger.ZERO,
BigInteger.valueOf(4), progression, rewards, null).join();
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, zeroXp.status());
assertEquals(BigInteger.valueOf(30), zeroXp.record().orElseThrow().coins(),
"A non-spacing zero-XP result remains coin-eligible");
var cappedRules = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.TEN, 1);
var capped = storage.awardCombatReward(live.id(), zeroXp.record().orElseThrow().revision(), UUID.randomUUID(),
BigInteger.TEN, BigInteger.valueOf(5), cappedRules, rewards, null).join();
assertEquals(CharacterStorage.CombatAwardStatus.AT_CAP, capped.status());
assertEquals(BigInteger.valueOf(35), capped.record().orElseThrow().coins(),
"Maximum-level status alone must not suppress coins");
}
}
@Test
void stableCoinDebitIsAtomicIdempotentAndNeverOverdraws() throws Exception {
var repository = new YamlCharacterRepository(data);
@@ -46,14 +46,77 @@ class PlayerFeedbackPreferencesTest {
try (var reopened = new PlayerFeedbackPreferences(data)) { assertTrue(reopened.enabled(account)); }
}
@Test void auraDefaultsOffAndRemainsIndependentUnderAllParticlePrecedenceAcrossRestart() throws Exception {
UUID account = UUID.randomUUID();
java.nio.file.Files.writeString(data.resolve("player-preferences.yml"),
"unrelated: keep\nplayers:\n " + account + ":\n combat-feedback: false\n other: value\n");
try (var preferences = new PlayerFeedbackPreferences(data)) {
assertFalse(preferences.playerAurasEnabled(account));
assertTrue(preferences.allParticlesEnabled(account));
assertFalse(preferences.auraParticlesEnabled(account));
var auraOn = preferences.setPlayerAuras(account, true).toCompletableFuture()
.get(5, TimeUnit.SECONDS);
assertFalse(preferences.playerAurasEnabled(account), "Durability precedes effective publication");
assertTrue(preferences.publish(auraOn));
assertTrue(preferences.auraParticlesEnabled(account));
var allOff = preferences.setAllParticles(account, false).toCompletableFuture()
.get(5, TimeUnit.SECONDS);
assertTrue(preferences.playerAurasEnabled(account), "Changing all preserves the saved aura value");
assertTrue(preferences.publish(allOff));
assertFalse(preferences.auraParticlesEnabled(account));
}
String saved = java.nio.file.Files.readString(data.resolve("player-preferences.yml"));
assertTrue(saved.contains("unrelated: keep")); assertTrue(saved.contains("other: value"));
try (var reopened = new PlayerFeedbackPreferences(data)) {
assertTrue(reopened.playerAurasEnabled(account));
assertFalse(reopened.allParticlesEnabled(account));
assertFalse(reopened.auraParticlesEnabled(account));
assertFalse(reopened.enabled(account), "Combat feedback remains independent");
var allOn = reopened.setAllParticles(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS);
assertTrue(reopened.publish(allOn));
assertTrue(reopened.auraParticlesEnabled(account));
}
}
@Test void competingAuraWritesRejectStalePublicationAndAcceptedShutdownWorkDrains() throws Exception {
UUID account = UUID.randomUUID();
var preferences = new PlayerFeedbackPreferences(data);
var enabled = preferences.setPlayerAuras(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS);
var disabled = preferences.setPlayerAuras(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS);
assertTrue(preferences.publish(disabled));
assertFalse(preferences.publish(enabled));
assertFalse(preferences.playerAurasEnabled(account));
var accepted = preferences.setPlayerAuras(account, true).toCompletableFuture();
var acceptedAll = preferences.setAllParticles(account, false).toCompletableFuture();
preferences.close();
preferences.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
assertNotNull(accepted.get(5, TimeUnit.SECONDS));
assertNotNull(acceptedAll.get(5, TimeUnit.SECONDS));
assertFalse(preferences.playerAurasEnabled(account), "A closed lifetime does not publish its callback");
assertTrue(preferences.allParticlesEnabled(account), "A closed lifetime retains its effective all-particle state");
try (var reopened = new PlayerFeedbackPreferences(data)) {
assertTrue(reopened.playerAurasEnabled(account), "Accepted shutdown work is durable on restart");
assertFalse(reopened.allParticlesEnabled(account), "Accepted all-particle work is durable on restart");
}
}
@Test void failedOrClosedWritesRetainTheEffectivePreference() throws Exception {
UUID account = UUID.randomUUID();
var preferences = new PlayerFeedbackPreferences(data);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
assertThrows(Exception.class, () -> preferences.set(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertThrows(Exception.class, () -> preferences.setPlayerAuras(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertThrows(Exception.class, () -> preferences.setAllParticles(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertTrue(preferences.enabled(account));
assertFalse(preferences.playerAurasEnabled(account));
assertTrue(preferences.allParticlesEnabled(account));
preferences.close();
assertThrows(Exception.class, () -> preferences.set(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertThrows(Exception.class, () -> preferences.setPlayerAuras(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertThrows(Exception.class, () -> preferences.setAllParticles(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertTrue(preferences.enabled(account));
assertFalse(preferences.playerAurasEnabled(account));
assertTrue(preferences.allParticlesEnabled(account));
}
}