Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba69a55124 | ||
|
|
db35c28f0b |
No files matched your search
@@ -45,6 +45,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.session.SessionVitals vitals;
|
||||
private games.dmg.fantasystick.session.SessionVisuals sessionVisuals;
|
||||
private games.dmg.fantasystick.combat.BasicAttack basicAttack;
|
||||
private games.dmg.fantasystick.party.CombatExperience combatExperience;
|
||||
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@Override public void onEnable() {
|
||||
@@ -69,6 +71,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
adventureSettings = new games.dmg.fantasystick.persistence.AdventureSettings(getDataFolder().toPath(), adventure);
|
||||
var switchDelay = switchDelay();
|
||||
var rules = progressionRules();
|
||||
var rewardRules = combatRewardRules();
|
||||
var factory = new CharacterFactory();
|
||||
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
|
||||
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
|
||||
@@ -109,12 +112,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
|
||||
vitals = new games.dmg.fantasystick.session.SessionVitals();
|
||||
sessions.attachVitals(vitals);
|
||||
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
|
||||
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
|
||||
var player = getServer().getPlayer(id);
|
||||
return player != null && player.isOnline();
|
||||
});
|
||||
partyEvents = new games.dmg.fantasystick.party.PartyEvents(parties);
|
||||
partyCombat = new games.dmg.fantasystick.party.PartyCombat(parties, sessions);
|
||||
combatExperience = new games.dmg.fantasystick.party.CombatExperience(characters, rules, rewardRules,
|
||||
sessions, Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
|
||||
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()));
|
||||
books = new games.dmg.fantasystick.book.CharacterBookController(characters, rules, sessions, nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Character book failed for player " + problem.player()
|
||||
+ "; character " + problem.character(), problem.cause()), parties, getServer(), id -> {
|
||||
@@ -142,7 +149,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(partyEvents, this);
|
||||
getServer().getPluginManager().registerEvents(partyCombat, this);
|
||||
partyCombat.install(this);
|
||||
getServer().getPluginManager().registerEvents(combatExperience, this);
|
||||
combatExperience.install(this);
|
||||
getServer().getPluginManager().registerEvents(localBasicAttack, this);
|
||||
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
getServer().getPluginManager().registerEvents(gameModes, this);
|
||||
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
|
||||
@@ -181,6 +191,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingVitals = vitals; vitals = null;
|
||||
var closingVisuals = sessionVisuals; sessionVisuals = null;
|
||||
var closingBasicAttack = basicAttack; basicAttack = null;
|
||||
var closingCombatExperience = combatExperience; combatExperience = null;
|
||||
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
|
||||
// Retire client resources independently of successful ordinary-equipment restoration.
|
||||
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
|
||||
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
|
||||
@@ -191,6 +203,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
}
|
||||
if (closingBooks != null) cleanup("character book", closingBooks::close);
|
||||
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
|
||||
if (closingCombatExperience != null) cleanup("combat experience", closingCombatExperience::close);
|
||||
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
|
||||
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
|
||||
if (closingParties != null) cleanup("parties", closingParties::close);
|
||||
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
|
||||
@@ -202,7 +216,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingChat);
|
||||
unregister(closingPartyEvents);
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingCombatExperience);
|
||||
unregister(closingBasicAttack);
|
||||
unregister(closingNutrition);
|
||||
unregister(closingBooks);
|
||||
unregister(closingEvents);
|
||||
unregister(closingCrafting);
|
||||
@@ -244,6 +260,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules() {
|
||||
try {
|
||||
var passive = new java.math.BigInteger(getConfig().getString("passive-xp", "1"));
|
||||
var ordinary = new java.math.BigInteger(getConfig().getString("ordinary-xp", "10"));
|
||||
var boss = new java.math.BigInteger(getConfig().getString("boss-xp", "100"));
|
||||
var spacing = new java.math.BigDecimal(getConfig().getString("restricted-kill-spacing-blocks", "3"));
|
||||
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, boss, spacing);
|
||||
} catch (NumberFormatException invalid) {
|
||||
throw new IllegalArgumentException("Combat reward settings must be decimal numbers", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private java.time.Duration switchDelay() {
|
||||
Object configured = getConfig().get("character-swap-cooldown-seconds", 900);
|
||||
String value = String.valueOf(configured);
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
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;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.EntityRemoveEvent;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
|
||||
/** Accepted native contribution capture and durable character-bound XP delivery. */
|
||||
public final class CombatExperience implements Listener, AutoCloseable {
|
||||
public record Failure(UUID character, Throwable cause) { }
|
||||
private record Defeat(EquipmentSessions.Active active, int level) { }
|
||||
private static final Set<EntityType> PASSIVE = Set.of(
|
||||
EntityType.ALLAY, EntityType.ARMADILLO, EntityType.AXOLOTL, EntityType.BAT, EntityType.CAMEL,
|
||||
EntityType.CAT, EntityType.CHICKEN, EntityType.COD, EntityType.COPPER_GOLEM, EntityType.COW,
|
||||
EntityType.DONKEY, EntityType.FOX, EntityType.FROG, EntityType.GLOW_SQUID, EntityType.HAPPY_GHAST,
|
||||
EntityType.HORSE, EntityType.MOOSHROOM, EntityType.MULE, EntityType.NAUTILUS, EntityType.OCELOT,
|
||||
EntityType.PARROT, EntityType.PIG, EntityType.RABBIT, EntityType.SALMON, EntityType.SHEEP,
|
||||
EntityType.SKELETON_HORSE, EntityType.SNIFFER, EntityType.SNOW_GOLEM, EntityType.SQUID,
|
||||
EntityType.STRIDER, EntityType.TADPOLE, EntityType.TROPICAL_FISH, EntityType.TURTLE,
|
||||
EntityType.VILLAGER, EntityType.WANDERING_TRADER, EntityType.ZOMBIE_HORSE);
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final ProgressionRules progression;
|
||||
private final CombatRewardRules rewards;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Function<UUID, Player> players;
|
||||
private final Consumer<Failure> failures;
|
||||
private final CombatSources sources;
|
||||
private final CombatContributions contributions;
|
||||
private final Map<PlayerDeathEvent, Defeat> defeats = new IdentityHashMap<>();
|
||||
private final Map<UUID, PendingAward> pending = new ConcurrentHashMap<>();
|
||||
private final java.util.concurrent.ConcurrentLinkedQueue<UUID> retries = new java.util.concurrent.ConcurrentLinkedQueue<>();
|
||||
private org.bukkit.scheduler.BukkitTask retryTask;
|
||||
private boolean closed;
|
||||
|
||||
private final class PendingAward {
|
||||
final UUID id = UUID.randomUUID();
|
||||
final UUID character;
|
||||
final BigInteger reward;
|
||||
final CombatRewardRules.KillPosition restricted;
|
||||
BigInteger expectedRevision;
|
||||
boolean running;
|
||||
PendingAward(UUID character, BigInteger reward, CombatRewardRules.KillPosition restricted) {
|
||||
this.character = character; this.reward = reward; this.restricted = restricted;
|
||||
}
|
||||
}
|
||||
|
||||
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
|
||||
EquipmentSessions sessions, Clock clock, Consumer<Runnable> nextTick,
|
||||
Function<UUID, Player> players, Consumer<Failure> failures) {
|
||||
this.characters = Objects.requireNonNull(characters); this.progression = Objects.requireNonNull(progression);
|
||||
this.rewards = Objects.requireNonNull(rewards); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.players = Objects.requireNonNull(players); this.failures = Objects.requireNonNull(failures);
|
||||
sources = new CombatSources(sessions); contributions = new CombatContributions(rewards);
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
if (closed || retryTask != null) throw new IllegalStateException("Combat experience is already installed or closed");
|
||||
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::retryPending, 20, 20);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (closed || event.getFinalDamage() <= 0) return;
|
||||
var origin = source(event);
|
||||
if (origin == null || origin.session() == null || !sources.current(origin)
|
||||
|| event.getEntity().getUniqueId().equals(origin.player())) return;
|
||||
contributions.accept(event.getEntity().getUniqueId(), origin.character(), clock.instant());
|
||||
}
|
||||
|
||||
private CombatSources.Origin source(EntityDamageEvent event) {
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
if (ongoing != null) return ongoing.source();
|
||||
}
|
||||
var origin = sources.read(event.getDamageSource().getDirectEntity());
|
||||
if (origin == null && event instanceof EntityDamageByEntityEvent byEntity) origin = sources.read(byEntity.getDamager());
|
||||
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
|
||||
return origin;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
||||
public void beforePlayerDeath(PlayerDeathEvent event) {
|
||||
if (closed) return;
|
||||
var active = sessions.active(event.getEntity().getUniqueId()).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
if (record != null) defeats.put(event, new Defeat(active, record.level()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void playerDeath(PlayerDeathEvent event) {
|
||||
var defeat = defeats.remove(event);
|
||||
if (closed || defeat == null || !event.isCancelled() || sessions.current(defeat.active())) return;
|
||||
award(event.getEntity(), CombatRewardRules.Target.FANTASY_CHARACTER, defeat.level(), defeat.active().character());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void mobDeath(EntityDeathEvent event) {
|
||||
if (closed || event instanceof PlayerDeathEvent) return;
|
||||
award(event.getEntity(), classify(event.getEntity()), 0, null);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void removed(EntityRemoveEvent event) {
|
||||
if (!closed && event.getCause() != EntityRemoveEvent.Cause.DEATH) {
|
||||
contributions.invalidate(event.getEntity().getUniqueId());
|
||||
}
|
||||
}
|
||||
|
||||
private void award(LivingEntity target, CombatRewardRules.Target kind, int level, UUID defeatedCharacter) {
|
||||
Instant now = clock.instant();
|
||||
var contributors = new ArrayList<>(contributions.consume(target.getUniqueId(), now));
|
||||
if (defeatedCharacter != null) contributors.remove(defeatedCharacter);
|
||||
if (contributors.isEmpty()) return;
|
||||
BigInteger share = rewards.share(rewards.pool(kind, level), contributors.size());
|
||||
CombatRewardRules.KillPosition restricted = kind == CombatRewardRules.Target.PASSIVE
|
||||
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
|
||||
for (UUID character : contributors) {
|
||||
var operation = new PendingAward(character, share, restricted);
|
||||
pending.put(operation.id, operation);
|
||||
attempt(operation);
|
||||
}
|
||||
}
|
||||
|
||||
private static CombatRewardRules.KillPosition position(Location location) {
|
||||
var world = Objects.requireNonNull(location.getWorld(), "Kill location has no world").getUID();
|
||||
return new CombatRewardRules.KillPosition(Objects.requireNonNull(world, "Kill world has no UUID"),
|
||||
BigDecimal.valueOf(location.getX()), BigDecimal.valueOf(location.getY()), BigDecimal.valueOf(location.getZ()));
|
||||
}
|
||||
|
||||
private void attempt(PendingAward operation) {
|
||||
synchronized (operation) {
|
||||
if (closed || operation.running || !pending.containsKey(operation.id)) return;
|
||||
operation.running = true;
|
||||
}
|
||||
if (operation.expectedRevision == null) {
|
||||
characters.find(operation.character).whenComplete((record, failure) -> {
|
||||
synchronized (operation) { operation.running = false; }
|
||||
if (failure != null) { fail(operation, failure); return; }
|
||||
if (record.isEmpty() || record.orElseThrow().dead()) { pending.remove(operation.id); return; }
|
||||
operation.expectedRevision = record.orElseThrow().revision();
|
||||
attempt(operation);
|
||||
});
|
||||
return;
|
||||
}
|
||||
characters.awardCombatExperience(operation.character, operation.expectedRevision, operation.reward,
|
||||
progression, rewards, operation.restricted).whenComplete((award, failure) -> {
|
||||
synchronized (operation) { operation.running = false; }
|
||||
if (failure != null) { fail(operation, failure); return; }
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
|
||||
operation.expectedRevision = award.record().orElseThrow().revision(); attempt(operation); return;
|
||||
}
|
||||
pending.remove(operation.id);
|
||||
if (award.record().isEmpty() || award.status() == CharacterStorage.CombatAwardStatus.UNAVAILABLE) return;
|
||||
try { nextTick.accept(() -> acknowledge(operation, award)); }
|
||||
catch (RuntimeException rejected) { report(operation.character, rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void fail(PendingAward operation, Throwable failure) {
|
||||
report(operation.character, failure);
|
||||
if (!closed && pending.containsKey(operation.id)) retries.offer(operation.id);
|
||||
}
|
||||
|
||||
public void retryPending() {
|
||||
if (closed) return;
|
||||
for (int attempted = 0; attempted < 64; attempted++) {
|
||||
UUID id = retries.poll();
|
||||
if (id == null) break;
|
||||
var operation = pending.get(id);
|
||||
if (operation != null) attempt(operation);
|
||||
}
|
||||
}
|
||||
|
||||
private void acknowledge(PendingAward operation, CharacterStorage.CombatAward award) {
|
||||
if (closed) return;
|
||||
CharacterRecord saved = award.record().orElseThrow();
|
||||
var active = sessions.activeCharacter(operation.character).orElse(null);
|
||||
if (active != null && !sessions.acknowledgeProgress(active, saved.revision().subtract(BigInteger.ONE), saved)
|
||||
&& !sessions.currentRecord(active).filter(saved::equals).isPresent()) {
|
||||
report(operation.character, new IllegalStateException("Saved combat progress could not synchronize with active session"));
|
||||
return;
|
||||
}
|
||||
Player player = active == null ? null : players.apply(active.player());
|
||||
if (player == null || !player.isOnline()) return;
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.APPLIED) {
|
||||
player.sendMessage("Earned " + operation.reward + " fantasy XP.");
|
||||
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);
|
||||
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
|
||||
}
|
||||
} else if (award.status() == CharacterStorage.CombatAwardStatus.SPACING_BLOCKED) {
|
||||
player.sendMessage("No fantasy XP: this restricted kill was too close to your previous one in this world.");
|
||||
} else if (award.status() == CharacterStorage.CombatAwardStatus.AT_CAP) {
|
||||
player.sendMessage("No fantasy XP: this character is already at the configured maximum level.");
|
||||
} else if (award.status() == CharacterStorage.CombatAwardStatus.UNCHANGED) {
|
||||
player.sendMessage("No fantasy XP: this target's configured reward is zero.");
|
||||
}
|
||||
}
|
||||
|
||||
static CombatRewardRules.Target classify(LivingEntity entity) {
|
||||
EntityType type = entity.getType();
|
||||
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON) return CombatRewardRules.Target.BOSS;
|
||||
return PASSIVE.contains(type) ? CombatRewardRules.Target.PASSIVE : CombatRewardRules.Target.ORDINARY;
|
||||
}
|
||||
|
||||
private void report(UUID character, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, failure)); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics never replay or acknowledge a reward. */ }
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
if (retryTask != null) retryTask.cancel();
|
||||
defeats.clear(); pending.clear(); retries.clear(); contributions.close();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.progression.CombatRewardRules;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
@@ -16,7 +17,16 @@ public record CharacterRecord(
|
||||
UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout,
|
||||
cooldowns, Map.of(), dead, deathId, diedAt);
|
||||
}
|
||||
|
||||
public CharacterRecord {
|
||||
Objects.requireNonNull(id);
|
||||
Objects.requireNonNull(name);
|
||||
@@ -28,6 +38,7 @@ public record CharacterRecord(
|
||||
learned = Set.copyOf(learned);
|
||||
loadout = List.copyOf(loadout);
|
||||
cooldowns = Map.copyOf(cooldowns);
|
||||
restrictedKills = Map.copyOf(restrictedKills);
|
||||
if (revision.signum() < 0 || experience.signum() < 0 || unspentPoints.signum() < 0 || level < 1
|
||||
|| !stats.keySet().equals(EnumSet.allOf(Stat.class))
|
||||
|| stats.values().stream().anyMatch(value -> value.signum() < 0)
|
||||
@@ -35,6 +46,8 @@ public record CharacterRecord(
|
||||
|| health.compareTo(new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN))) > 0
|
||||
|| loadout.size() != 7 || learned.stream().anyMatch(String::isBlank)
|
||||
|| cooldowns.keySet().stream().anyMatch(String::isBlank)
|
||||
|| restrictedKills.size() > 1024
|
||||
|| restrictedKills.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(entry.getValue().world()))
|
||||
|| (deathId == null) != (diedAt == null) || dead && deathId == null) {
|
||||
throw new IllegalArgumentException("Invalid character record");
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (old.name().equals(name)) return Optional.of(old);
|
||||
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(),
|
||||
false, old.deathId(), old.diedAt());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(renamed, old.revision());
|
||||
return Optional.of(renamed);
|
||||
});
|
||||
@@ -246,12 +246,54 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
int gained = Math.max(0, level - old.level());
|
||||
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.cooldowns(), false, old.deathId(), old.diedAt());
|
||||
old.loadout(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
|
||||
});
|
||||
}
|
||||
|
||||
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
|
||||
public record CombatAward(CombatAwardStatus status, Optional<CharacterRecord> record, int levels) { }
|
||||
|
||||
/** Persist one combat share and its restricted-kill reference in the same character revision. */
|
||||
public CompletableFuture<CombatAward> awardCombatExperience(UUID characterId, BigInteger expectedRevision,
|
||||
BigInteger reward, ProgressionRules progression,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules rewards,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules.KillPosition restrictedKill) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision);
|
||||
Objects.requireNonNull(reward); Objects.requireNonNull(progression); Objects.requireNonNull(rewards);
|
||||
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new CombatAward(CombatAwardStatus.UNAVAILABLE, Optional.empty(), 0);
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) {
|
||||
return new CombatAward(CombatAwardStatus.STALE, Optional.of(old), 0);
|
||||
}
|
||||
boolean eligible = restrictedKill == null || old.restrictedKills().get(restrictedKill.world()) == null
|
||||
|| rewards.spaced(old.restrictedKills().get(restrictedKill.world()), restrictedKill);
|
||||
var history = new java.util.HashMap<>(old.restrictedKills());
|
||||
if (restrictedKill != null) history.put(restrictedKill.world(), restrictedKill);
|
||||
var cap = progression.xpPerLevel().multiply(BigInteger.valueOf(progression.maximumLevel() - 1L));
|
||||
boolean atCap = old.level() >= progression.maximumLevel() || old.experience().compareTo(cap) >= 0;
|
||||
var experience = eligible && !atCap ? old.experience().add(reward).min(cap) : old.experience();
|
||||
int level = atCap ? old.level() : experience.divide(progression.xpPerLevel()).intValueExact() + 1;
|
||||
int gained = Math.max(0, level - old.level());
|
||||
var status = !eligible ? CombatAwardStatus.SPACING_BLOCKED : atCap ? CombatAwardStatus.AT_CAP
|
||||
: experience.equals(old.experience()) ? CombatAwardStatus.UNCHANGED : CombatAwardStatus.APPLIED;
|
||||
if (history.equals(old.restrictedKills()) && experience.equals(old.experience())) {
|
||||
return new CombatAward(status, Optional.of(old), 0);
|
||||
}
|
||||
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.cooldowns(), history, false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new CombatAward(status, Optional.of(saved), gained);
|
||||
});
|
||||
}
|
||||
|
||||
public enum StatPurchaseStatus { APPLIED, STALE, NO_POINTS, UNAVAILABLE }
|
||||
public record StatPurchase(StatPurchaseStatus status, Optional<CharacterRecord> record) { }
|
||||
|
||||
@@ -290,7 +332,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var gain = rawMaximum.min(nativeLimit).subtract(oldMaximum);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
|
||||
currentHealth.add(gain), old.learned(), old.loadout(), old.cooldowns(), false,
|
||||
currentHealth.add(gain), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false,
|
||||
old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
|
||||
@@ -310,7 +352,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.cooldowns(),
|
||||
false, old.deathId(), old.diedAt());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return Optional.of(saved);
|
||||
});
|
||||
@@ -341,7 +383,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (old.dead() || expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
|
||||
var dead = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.ZERO, old.learned(),
|
||||
old.loadout(), old.cooldowns(), true, deathId, diedAt);
|
||||
old.loadout(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt);
|
||||
repository.replace(dead, old.revision());
|
||||
return Optional.of(dead);
|
||||
});
|
||||
@@ -361,7 +403,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
}
|
||||
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), old.maximumHealth(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), false, old.deathId(), old.diedAt());
|
||||
old.loadout(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(restored, old.revision());
|
||||
return Optional.of(restored);
|
||||
});
|
||||
|
||||
@@ -46,6 +46,13 @@ final class CharacterYaml {
|
||||
record.cooldowns().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry ->
|
||||
cooldowns.add(Map.of("ability", entry.getKey(), "ready-at", entry.getValue().toString())));
|
||||
root.put("cooldowns", cooldowns);
|
||||
List<Map<String, String>> restrictedKills = new ArrayList<>();
|
||||
record.restrictedKills().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
|
||||
var position = entry.getValue();
|
||||
restrictedKills.add(Map.of("world", entry.getKey().toString(), "x", position.x().toString(),
|
||||
"y", position.y().toString(), "z", position.z().toString()));
|
||||
});
|
||||
root.put("restricted-kills", restrictedKills);
|
||||
root.put("dead", record.dead());
|
||||
if (record.deathId() != null) {
|
||||
root.put("death-id", record.deathId().toString());
|
||||
@@ -74,11 +81,19 @@ final class CharacterYaml {
|
||||
throw new IllegalArgumentException("Duplicate cooldown");
|
||||
}
|
||||
}
|
||||
Map<UUID, games.dmg.fantasystick.progression.CombatRewardRules.KillPosition> restrictedKills = new LinkedHashMap<>();
|
||||
for (Object value : root.containsKey("restricted-kills") ? list(root.get("restricted-kills")) : List.of()) {
|
||||
Map<?, ?> row = object(value);
|
||||
UUID world = UUID.fromString(text(row.get("world")));
|
||||
var position = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(world,
|
||||
new BigDecimal(text(row.get("x"))), new BigDecimal(text(row.get("y"))), new BigDecimal(text(row.get("z"))));
|
||||
if (restrictedKills.putIfAbsent(world, position) != null) throw new IllegalArgumentException("Duplicate restricted-kill world");
|
||||
}
|
||||
if (!(root.get("dead") instanceof Boolean dead)) throw new IllegalArgumentException("Missing dead flag");
|
||||
return new CharacterRecord(UUID.fromString(text(root.get("character-id"))), text(root.get("name")),
|
||||
number(root.get("revision")), stats, number(root.get("level")).intValueExact(),
|
||||
number(root.get("experience")), number(root.get("unspent-points")),
|
||||
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")), cooldowns,
|
||||
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")), cooldowns, restrictedKills,
|
||||
dead, root.containsKey("death-id") ? UUID.fromString(text(root.get("death-id"))) : null,
|
||||
root.containsKey("died-at") ? instant(root.get("died-at")) : null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Server-thread bounded contribution ledger keyed by target and originating character. */
|
||||
public final class CombatContributions implements AutoCloseable {
|
||||
private final CombatRewardRules rules;
|
||||
private final Map<UUID, Map<UUID, Instant>> targets = new HashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
public CombatContributions(CombatRewardRules rules) { this.rules = Objects.requireNonNull(rules); }
|
||||
|
||||
public void accept(UUID target, UUID character, Instant acceptedAt) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(character); Objects.requireNonNull(acceptedAt);
|
||||
if (closed) return;
|
||||
targets.computeIfAbsent(target, ignored -> new HashMap<>()).merge(character, acceptedAt,
|
||||
(previous, next) -> previous.isAfter(next) ? previous : next);
|
||||
}
|
||||
|
||||
public Set<UUID> consume(UUID target, Instant deathAt) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(deathAt);
|
||||
if (closed) return Set.of();
|
||||
var contributions = targets.remove(target);
|
||||
if (contributions == null) return Set.of();
|
||||
var eligible = new LinkedHashSet<UUID>();
|
||||
contributions.forEach((character, acceptedAt) -> {
|
||||
if (rules.contributes(acceptedAt, deathAt)) eligible.add(character);
|
||||
});
|
||||
return Set.copyOf(eligible);
|
||||
}
|
||||
|
||||
public void invalidate(UUID target) { if (!closed) targets.remove(Objects.requireNonNull(target)); }
|
||||
|
||||
public void sweep(Instant now) {
|
||||
Objects.requireNonNull(now);
|
||||
if (closed) return;
|
||||
targets.values().forEach(contributions -> contributions.entrySet().removeIf(entry -> !rules.contributes(entry.getValue(), now)));
|
||||
targets.values().removeIf(Map::isEmpty);
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; targets.clear(); }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Validated combat reward arithmetic and location policy; native classification lives at the adapter. */
|
||||
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
|
||||
BigDecimal restrictedSpacing) {
|
||||
public enum Target { PASSIVE, ORDINARY, BOSS, FANTASY_CHARACTER }
|
||||
public static final Duration CONTRIBUTION_LIFETIME = Duration.ofMinutes(5);
|
||||
|
||||
public record KillPosition(UUID world, BigDecimal x, BigDecimal y, BigDecimal z) {
|
||||
public KillPosition {
|
||||
Objects.requireNonNull(world);
|
||||
x = coordinate(x); y = coordinate(y); z = coordinate(z);
|
||||
}
|
||||
private static BigDecimal coordinate(BigDecimal value) {
|
||||
Objects.requireNonNull(value);
|
||||
if (!Double.isFinite(value.doubleValue())) throw new IllegalArgumentException("Kill coordinate is not finite");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public CombatRewardRules {
|
||||
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool);
|
||||
Objects.requireNonNull(bossPool); Objects.requireNonNull(restrictedSpacing);
|
||||
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || bossPool.signum() < 0
|
||||
|| restrictedSpacing.signum() < 0 || !Double.isFinite(restrictedSpacing.doubleValue())) {
|
||||
throw new IllegalArgumentException("Combat reward values must be finite and nonnegative");
|
||||
}
|
||||
}
|
||||
|
||||
public static CombatRewardRules defaults() {
|
||||
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), BigDecimal.valueOf(3));
|
||||
}
|
||||
|
||||
public BigInteger pool(Target target, int defeatedLevel) {
|
||||
Objects.requireNonNull(target);
|
||||
if (target == Target.FANTASY_CHARACTER) {
|
||||
if (defeatedLevel < 1) throw new IllegalArgumentException("Fantasy character level must be positive");
|
||||
return BigInteger.TEN.multiply(BigInteger.valueOf(defeatedLevel));
|
||||
}
|
||||
return switch (target) {
|
||||
case PASSIVE -> passivePool;
|
||||
case ORDINARY -> ordinaryPool;
|
||||
case BOSS -> bossPool;
|
||||
case FANTASY_CHARACTER -> throw new IllegalStateException("Handled fantasy character");
|
||||
};
|
||||
}
|
||||
|
||||
public BigInteger share(BigInteger pool, int contributors) {
|
||||
Objects.requireNonNull(pool);
|
||||
if (pool.signum() < 0 || contributors < 1) throw new IllegalArgumentException("Invalid reward share");
|
||||
var count = BigInteger.valueOf(contributors);
|
||||
return pool.add(count).subtract(BigInteger.ONE).divide(count);
|
||||
}
|
||||
|
||||
public boolean contributes(Instant acceptedAt, Instant deathAt) {
|
||||
Objects.requireNonNull(acceptedAt); Objects.requireNonNull(deathAt);
|
||||
return !acceptedAt.isAfter(deathAt) && !deathAt.isAfter(acceptedAt.plus(CONTRIBUTION_LIFETIME));
|
||||
}
|
||||
|
||||
public boolean spaced(KillPosition previous, KillPosition current) {
|
||||
Objects.requireNonNull(previous); Objects.requireNonNull(current);
|
||||
if (!previous.world().equals(current.world())) return true;
|
||||
BigDecimal dx = current.x().subtract(previous.x());
|
||||
BigDecimal dy = current.y().subtract(previous.y());
|
||||
BigDecimal dz = current.z().subtract(previous.z());
|
||||
BigDecimal squared = dx.multiply(dx).add(dy.multiply(dy)).add(dz.multiply(dz));
|
||||
return squared.compareTo(restrictedSpacing.multiply(restrictedSpacing)) >= 0;
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,14 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
|
||||
}
|
||||
|
||||
/** Current active holder for a transferable identity, used only to synchronize acknowledged character writes. */
|
||||
public synchronized Optional<Active> activeCharacter(UUID character) {
|
||||
Objects.requireNonNull(character);
|
||||
return active.values().stream().filter(attempt -> attempt.record != null && attempt.character.equals(character))
|
||||
.filter(attempt -> current(new Active(attempt.owner, attempt.session, attempt.character)))
|
||||
.map(attempt -> new Active(attempt.owner, attempt.session, attempt.character)).findFirst();
|
||||
}
|
||||
|
||||
/** Current transferable character snapshot for a still-authorized server-thread action. */
|
||||
public synchronized Optional<CharacterRecord> currentRecord(Active session) {
|
||||
var attempt = active.get(session.player());
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
|
||||
/** Active-session-only hunger/exhaustion suppression; explicit non-food healing remains native. */
|
||||
public final class NutritionPolicy implements Listener, AutoCloseable {
|
||||
private final EquipmentSessions sessions;
|
||||
private boolean closed;
|
||||
|
||||
public NutritionPolicy(EquipmentSessions sessions) {
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void food(org.bukkit.event.entity.FoodLevelChangeEvent event) {
|
||||
if (!active(event.getEntity())) return;
|
||||
event.setCancelled(true);
|
||||
var player = (Player) event.getEntity();
|
||||
player.setFoodLevel(20);
|
||||
player.setExhaustion(0);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void exhaustion(org.bukkit.event.entity.EntityExhaustionEvent event) {
|
||||
if (!active(event.getEntity())) return;
|
||||
event.setCancelled(true);
|
||||
var player = (Player) event.getEntity();
|
||||
player.setFoodLevel(20);
|
||||
player.setExhaustion(0);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void healing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
|
||||
if (event.getRegainReason() == org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED
|
||||
&& active(event.getEntity())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
private boolean active(org.bukkit.entity.Entity entity) {
|
||||
return !closed && entity instanceof Player player
|
||||
&& sessions.active(player.getUniqueId()).isPresent();
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; }
|
||||
}
|
||||
@@ -57,10 +57,15 @@ public final class SessionVitals implements AutoCloseable {
|
||||
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
|
||||
player.setHealthScale(maximum);
|
||||
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
|
||||
player.setFoodLevel(20);
|
||||
player.setExhaustion(0);
|
||||
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
|
||||
write(player, new Mark(session, Phase.APPLIED, maximum, ordinary));
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
try { releaseMaximum(player, session); }
|
||||
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
|
||||
try { restoreNutrition(player, ordinary); }
|
||||
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
|
||||
try { restoreScale(player, maximum, ordinary); }
|
||||
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
|
||||
// Leave APPLYING proof and the acknowledged receipt: an uncertain health setter
|
||||
@@ -121,7 +126,7 @@ public final class SessionVitals implements AutoCloseable {
|
||||
// completion). Restore positive health first: a zero-health packet opens a client death
|
||||
// screen even when Paper cancels the death and the server-side player survives.
|
||||
player.setHealth(Math.min(ordinary.health(), Objects.requireNonNull(player.getAttribute(Attribute.MAX_HEALTH)).getValue()));
|
||||
player.setFoodLevel(ordinary.food()); player.setSaturation(ordinary.saturation()); player.setExhaustion(ordinary.exhaustion());
|
||||
restoreNutrition(player, ordinary);
|
||||
restoreScale(player, mark.maximum(), ordinary);
|
||||
write(player, new Mark(session, Phase.RELEASED, mark.maximum(), ordinary));
|
||||
return true;
|
||||
@@ -135,6 +140,12 @@ public final class SessionVitals implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private static void restoreNutrition(Player player, OrdinaryVitals ordinary) {
|
||||
player.setFoodLevel(ordinary.food());
|
||||
player.setSaturation(ordinary.saturation());
|
||||
player.setExhaustion(ordinary.exhaustion());
|
||||
}
|
||||
|
||||
private static void restoreScale(Player player, double maximum, OrdinaryVitals ordinary) {
|
||||
if (player.isHealthScaled() && player.getHealthScale() == maximum) {
|
||||
player.setHealthScale(ordinary.scale());
|
||||
|
||||
@@ -6,3 +6,9 @@ adventure-mode: true
|
||||
starting-points: 40
|
||||
xp-per-level: 100
|
||||
maximum-level: 30
|
||||
|
||||
# Character-bound combat rewards. Restricted hostile/neutral/boss history uses mob death positions.
|
||||
passive-xp: 1
|
||||
ordinary-xp: 10
|
||||
boss-xp: 100
|
||||
restricted-kill-spacing-blocks: 3
|
||||
@@ -0,0 +1,263 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CombatExperienceNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void packagedBasicAttackToBossDeathPersistsLevelAndUpdatesBookFeedbackExactlyOnce() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
assertTrue(f.order.contains("CombatExperience"));
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var world = mock(org.bukkit.World.class); var worldId = UUID.randomUUID(); when(world.getUID()).thenReturn(worldId);
|
||||
var target = mob(EntityType.WARDEN, new Location(world, 10, 65, -4));
|
||||
doAnswer(call -> {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getCausingEntity()).thenReturn(a.player);
|
||||
when(source.getDirectEntity()).thenReturn(a.player);
|
||||
var event = new EntityDamageByEntityEvent(a.player, target, DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0));
|
||||
f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(anyDouble(), same(a.player));
|
||||
|
||||
a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(attack);
|
||||
assertTrue(attack.isCancelled());
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
var death = new org.bukkit.event.entity.EntityDeathEvent(target, source, new ArrayList<>(), 17);
|
||||
f.dispatcher.accept(death);
|
||||
PartyBookNativeTest.drain(f);
|
||||
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var saved = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
PartyBookNativeTest.drain(f); // Deliver the post-commit main-thread acknowledgment and feedback.
|
||||
assertEquals(java.math.BigInteger.valueOf(100), saved.experience());
|
||||
assertEquals(2, saved.level());
|
||||
assertEquals(java.math.BigInteger.valueOf(41), saved.unspentPoints());
|
||||
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)));
|
||||
assertFalse(a.sounds.isEmpty());
|
||||
|
||||
a.core.inventory.getInventory().setSelectedSlot(0);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
String book = PartyBookNativeTest.text(a);
|
||||
assertTrue(book.contains("Level: 2")); assertTrue(book.contains("XP: 0/100"));
|
||||
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, source, new ArrayList<>(), 17));
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(saved, storage.find(a.core.record.id()).get().orElseThrow(), "One defeated target awards only once");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sharingProjectileExitPassiveExemptionAndPerCharacterRestrictedSpacingUseNativeEvents() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var c = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.players.put(c.core.id, c.player);
|
||||
f.activate(a); f.activate(b); f.activate(c);
|
||||
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, a.player, first, false);
|
||||
die(f, first); settle(f);
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, a).experience());
|
||||
assertEquals(java.math.BigInteger.ZERO, record(f, b).experience());
|
||||
|
||||
var near = mob(EntityType.BEE, new Location(world, 2, 64, 0)); // Neutral, regardless of aggression.
|
||||
hit(f, a.player, near, false);
|
||||
var arrow = PartyCombatNativeTest.projectile(f, b.player);
|
||||
hit(f, arrow, near, false);
|
||||
var ordinary = PartyCombatNativeTest.entity(org.bukkit.entity.Arrow.class);
|
||||
hit(f, ordinary, near, false); // Ordinary sources never enter the fantasy denominator.
|
||||
PartyCombatNativeTest.exit(f, b); // The arrow's originating character still owns its accepted contribution.
|
||||
die(f, near); settle(f);
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Blocked share is lost rather than redistributed");
|
||||
assertEquals(java.math.BigInteger.valueOf(5), record(f, b).experience(), "First restricted kill is eligible for this character");
|
||||
|
||||
var passive = mob(EntityType.COW, new Location(world, 2, 64, 0));
|
||||
hit(f, a.player, passive, false);
|
||||
hit(f, c.player, passive, false);
|
||||
// b is now ordinary; its direct damage cannot enter the fantasy denominator.
|
||||
hit(f, b.player, passive, false);
|
||||
die(f, passive); settle(f);
|
||||
assertEquals(java.math.BigInteger.valueOf(11), record(f, a).experience());
|
||||
assertEquals(java.math.BigInteger.valueOf(5), record(f, b).experience());
|
||||
assertEquals(java.math.BigInteger.ONE, record(f, c).experience(),
|
||||
"A one-XP passive pool rounds up to one for every fantasy contributor");
|
||||
|
||||
var exact = mob(EntityType.ZOMBIE, new Location(world, 5, 64, 0));
|
||||
hit(f, a.player, exact, false);
|
||||
hit(f, a.player, exact, true); // Cancelled repeats do not create another contributor.
|
||||
die(f, exact); settle(f);
|
||||
assertEquals(java.math.BigInteger.valueOf(21), record(f, a).experience(),
|
||||
"Passive kill did not reset history; exact three-block distance from blocked kill is eligible");
|
||||
assertEquals(new java.math.BigDecimal("5.0"), record(f, a).restrictedKills().values().iterator().next().x());
|
||||
|
||||
var repeatedPassive = mob(EntityType.SHEEP, new Location(world, 2, 64, 0));
|
||||
hit(f, a.player, repeatedPassive, false); die(f, repeatedPassive); settle(f);
|
||||
assertEquals(java.math.BigInteger.valueOf(22), record(f, a).experience(), "Same-location passive kills remain eligible");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedRewardSaveRetainsOneRetryableOperationWithoutReplayingTheTarget() {
|
||||
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
|
||||
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
var progression = games.dmg.fantasystick.progression.ProgressionRules.defaults();
|
||||
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(sessions.active(active.player())).thenReturn(java.util.Optional.of(active));
|
||||
when(sessions.current(active)).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"));
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), original.name(), java.math.BigInteger.ONE,
|
||||
original.stats(), original.level(), java.math.BigInteger.TEN, original.unspentPoints(), original.health(),
|
||||
original.learned(), original.loadout(), original.cooldowns(), original.restrictedKills(), false, null, null);
|
||||
var success = java.util.concurrent.CompletableFuture.completedFuture(
|
||||
new games.dmg.fantasystick.persistence.CharacterStorage.CombatAward(
|
||||
games.dmg.fantasystick.persistence.CharacterStorage.CombatAwardStatus.APPLIED, java.util.Optional.of(saved), 0));
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
when(storage.awardCombatExperience(eq(original.id()), any(), eq(java.math.BigInteger.TEN), same(progression), any(), any()))
|
||||
.thenAnswer(ignored -> writes.getAndIncrement() == 0 ? failed : success);
|
||||
var failures = new ArrayList<games.dmg.fantasystick.party.CombatExperience.Failure>();
|
||||
var service = new games.dmg.fantasystick.party.CombatExperience(storage, progression,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), sessions,
|
||||
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC), Runnable::run,
|
||||
ignored -> null, failures::add);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var target = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
|
||||
var damageSource = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(damageSource.getDirectEntity()).thenReturn(attacker); when(damageSource.getCausingEntity()).thenReturn(attacker);
|
||||
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)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
|
||||
assertEquals(1, failures.size());
|
||||
service.retryPending();
|
||||
verify(storage, times(2)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
|
||||
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
|
||||
service.retryPending();
|
||||
verify(storage, times(2)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
|
||||
service.close();
|
||||
}
|
||||
|
||||
@Test void validatedConfiguredPoolAndZeroSpacingReachTheRegisteredRewardPath() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"xp-per-level: 10\nordinary-xp: 25\nrestricted-kill-spacing-blocks: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
for (int index = 0; index < 2; index++) {
|
||||
var target = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
|
||||
hit(f, a.player, target, false); die(f, target); settle(f);
|
||||
}
|
||||
assertEquals(java.math.BigInteger.valueOf(50), record(f, a).experience());
|
||||
assertEquals(6, record(f, a).level());
|
||||
assertEquals(java.math.BigInteger.valueOf(45), record(f, a).unspentPoints());
|
||||
assertEquals(5, a.sounds.size(), "Every gained level has its own feedback sound");
|
||||
for (int level = 2; level <= 6; level++) {
|
||||
int expected = level;
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Level " + expected)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void interceptedFantasyDefeatUsesVictimLevelPoolAndCancelledDamageCreatesNoCredit() throws Exception {
|
||||
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);
|
||||
hit(f, a.player, b.player, true);
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
var cancelled = new org.bukkit.event.entity.PlayerDeathEvent(b.player, source, new ArrayList<>(), 0,
|
||||
net.kyori.adventure.text.Component.text("defeat"), true);
|
||||
f.dispatcher.accept(cancelled); settle(f);
|
||||
assertTrue(cancelled.isCancelled());
|
||||
assertEquals(java.math.BigInteger.ZERO, record(f, a).experience(), "Cancelled damage never contributes");
|
||||
}
|
||||
|
||||
var second = data.resolve("accepted"); java.nio.file.Files.createDirectories(second);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(second)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(second);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(second);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b);
|
||||
hit(f, a.player, b.player, false);
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
var defeat = new org.bukkit.event.entity.PlayerDeathEvent(b.player, source, new ArrayList<>(), 0,
|
||||
net.kyori.adventure.text.Component.text("defeat"), true);
|
||||
f.dispatcher.accept(defeat); settle(f); settle(f);
|
||||
assertTrue(defeat.isCancelled());
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Level-one fantasy defeat supplies a ten-XP pool");
|
||||
assertTrue(record(f, b).dead());
|
||||
f.dispatcher.accept(defeat); settle(f);
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Repeated intercepted callback cannot award twice");
|
||||
}
|
||||
}
|
||||
|
||||
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Entity source,
|
||||
org.bukkit.entity.Entity target, boolean cancelled) {
|
||||
var damage = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(damage.getCausingEntity()).thenReturn(source);
|
||||
when(damage.getDirectEntity()).thenReturn(source);
|
||||
var event = new EntityDamageByEntityEvent(source, target, DamageCause.ENTITY_ATTACK, damage, 4);
|
||||
event.setCancelled(cancelled); f.dispatcher.accept(event); return event;
|
||||
}
|
||||
|
||||
private static void die(FantasyStickLifecycleNativeTest.Fixture f, LivingEntity target) {
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target,
|
||||
mock(org.bukkit.damage.DamageSource.class), new ArrayList<>(), 0));
|
||||
}
|
||||
|
||||
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
}
|
||||
|
||||
private static games.dmg.fantasystick.persistence.CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
return storage.find(actor.core.record.id()).get().orElseThrow();
|
||||
}
|
||||
|
||||
private static LivingEntity mob(EntityType type, Location location) {
|
||||
var entity = mock(LivingEntity.class);
|
||||
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(entity.getType()).thenReturn(type);
|
||||
when(entity.getLocation()).thenAnswer(ignored -> location.clone());
|
||||
when(entity.getWorld()).thenReturn(location.getWorld());
|
||||
when(entity.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -60,11 +60,15 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
|
||||
assertNotNull(fixture.packetListener);
|
||||
assertEquals(3, fixture.timers.size(), "Presentation, combat-provenance cleanup and session particles");
|
||||
assertEquals(4, fixture.timers.size(), "Presentation, provenance, combat-reward retry and session particles");
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
|
||||
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CombatExperience") < recipe);
|
||||
assertTrue(fixture.order.indexOf("BasicAttack") >= 0);
|
||||
assertTrue(fixture.order.indexOf("BasicAttack") < recipe);
|
||||
assertTrue(fixture.order.indexOf("NutritionPolicy") >= 0);
|
||||
assertTrue(fixture.order.indexOf("NutritionPolicy") < recipe);
|
||||
assertNotNull(fixture.command);
|
||||
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
|
||||
assertTrue(fixture.tasks.isEmpty());
|
||||
@@ -130,7 +134,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
|
||||
@Test
|
||||
void recipeCollisionAndPartialListenerRegistrationFailureRollbackOnlyThisEnableAttempt() throws Exception {
|
||||
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "presentation-timer")) {
|
||||
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "presentation-timer")) {
|
||||
boolean collision = stage.equals("collision");
|
||||
try (var fixture = new Fixture(data)) {
|
||||
var existing = new org.bukkit.inventory.ShapelessRecipe(CharacterSheets.RECIPE,
|
||||
@@ -866,6 +870,21 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidCombatRewardConfigurationFailsBeforeNativeRegistrationWithoutRewritingConfig() throws Exception {
|
||||
int scenario = 0;
|
||||
for (String row : List.of("passive-xp: -1", "ordinary-xp: 1.5", "boss-xp: nope",
|
||||
"restricted-kill-spacing-blocks: -0.1", "restricted-kill-spacing-blocks: 1e1000000")) {
|
||||
var directory = data.resolve("invalid-reward-" + scenario++); java.nio.file.Files.createDirectories(directory);
|
||||
String config = row + "\n"; java.nio.file.Files.writeString(directory.resolve("config.yml"), config);
|
||||
try (var f = new Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true), row);
|
||||
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty()); assertTrue(f.tasks.isEmpty());
|
||||
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
|
||||
assertEquals(config, java.nio.file.Files.readString(directory.resolve("config.yml")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidSwitchDurationsFailBeforeAnyNativeRegistrationWithoutRewritingConfig() throws Exception {
|
||||
int scenario = 0;
|
||||
for (String value : List.of("-1", "1.5", "true", "[]", "'abc'", "9223372036854775808", "9223372036854775807", "253402300800")) {
|
||||
@@ -1252,6 +1271,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
final List<String> messages = new ArrayList<>();
|
||||
final List<org.bukkit.inventory.ItemStack> books = new ArrayList<>();
|
||||
final List<Integer> particles = new ArrayList<>();
|
||||
final List<org.bukkit.Sound> sounds = new ArrayList<>();
|
||||
final net.minecraft.world.entity.ai.attributes.AttributeMap healthAttributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(
|
||||
net.minecraft.world.entity.ai.attributes.AttributeSupplier.builder().add(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).build());
|
||||
final net.minecraft.world.entity.ai.attributes.AttributeInstance maximumHealth = java.util.Objects.requireNonNull(
|
||||
@@ -1260,6 +1280,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
boolean healthScaled;
|
||||
double healthScale = 20;
|
||||
Runnable scaleMutation = () -> {};
|
||||
Runnable foodMutation = () -> {};
|
||||
java.util.function.Consumer<org.bukkit.event.player.PlayerGameModeChangeEvent> modeEvents = ignored -> { };
|
||||
final org.bukkit.craftbukkit.entity.CraftPlayer player;
|
||||
final net.minecraft.server.level.ServerPlayer nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
@@ -1293,7 +1314,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
case "getMaxHealth" -> maximumHealth.getValue();
|
||||
case "getFoodLevel" -> food.getFoodLevel();
|
||||
case "setFoodLevel" -> { food.setFoodLevel((Integer) args[0]); yield null; }
|
||||
case "setFoodLevel" -> { food.setFoodLevel((Integer) args[0]); foodMutation.run(); yield null; }
|
||||
case "getSaturation" -> food.getSaturationLevel();
|
||||
case "setSaturation" -> { food.setSaturation((Float) args[0]); yield null; }
|
||||
case "getExhaustion" -> food.exhaustionLevel;
|
||||
@@ -1314,6 +1335,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "sendMessage" -> { messages.add((String) args[0]); yield null; }
|
||||
case "openBook" -> { books.add(((org.bukkit.inventory.ItemStack) args[0]).clone()); yield null; }
|
||||
case "spawnParticle" -> { particles.add((Integer) args[2]); yield null; }
|
||||
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
|
||||
default -> Proxy.getInvocationHandler(core.player).invoke(core.player, method, args);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class NutritionNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void exitEatingAndReentryCannotHealTheSavedCharacterOrLoseTheNewOrdinaryNutrition() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player);
|
||||
a.player.setHealth(12); a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
|
||||
f.activate(a); a.player.setHealth(4);
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(12, a.player.getHealth()); assertEquals(7, a.player.getFoodLevel());
|
||||
a.player.setHealth(a.maximumHealth.getValue());
|
||||
a.player.setFoodLevel(20); a.player.setSaturation(12); a.player.setExhaustion(0.5f);
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
assertEquals(4, a.player.getHealth(), "Ordinary eating/healing cannot refill transferable fantasy health");
|
||||
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(a.maximumHealth.getValue(), a.player.getHealth());
|
||||
assertEquals(20, a.player.getFoodLevel()); assertEquals(12, a.player.getSaturation());
|
||||
assertEquals(0.5f, a.player.getExhaustion());
|
||||
var characters = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(0, characters.find(a.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.orElseThrow().health().compareTo(java.math.BigDecimal.valueOf(4)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedFantasyFoodSetterRestoresTheCapturedOrdinaryNutritionAndRetainsRecoveryProof() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player);
|
||||
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
|
||||
a.foodMutation = () -> {
|
||||
a.foodMutation = () -> {};
|
||||
throw new IllegalStateException("Injected food setter failure");
|
||||
};
|
||||
f.activate(a);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
assertTrue(sessions.active(a.core.id).isEmpty());
|
||||
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
|
||||
assertEquals(3, a.player.getExhaustion());
|
||||
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.orElseThrow().pending());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void fantasyDefeatRestoresOrdinaryNutritionInsteadOfLeavingTheFullFantasyState() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player);
|
||||
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
|
||||
f.activate(a);
|
||||
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
|
||||
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(a.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(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
|
||||
assertEquals(3, a.player.getExhaustion());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void activeFantasyModeKeepsFoodFullBlocksFoodHealingAndRestoresOrdinaryNutrition() 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);
|
||||
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
|
||||
ordinary.player.setFoodLevel(8); ordinary.player.setExhaustion(4);
|
||||
f.activate(a);
|
||||
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
|
||||
|
||||
var drain = new org.bukkit.event.entity.FoodLevelChangeEvent(a.player, 19);
|
||||
var exhaustion = new org.bukkit.event.entity.EntityExhaustionEvent(a.player,
|
||||
org.bukkit.event.entity.EntityExhaustionEvent.ExhaustionReason.SPRINT, 0.1f);
|
||||
var foodHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
|
||||
var potionHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC);
|
||||
var abilityHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
f.dispatcher.accept(drain); f.dispatcher.accept(exhaustion);
|
||||
f.dispatcher.accept(foodHealing); f.dispatcher.accept(potionHealing); f.dispatcher.accept(abilityHealing);
|
||||
assertTrue(drain.isCancelled()); assertTrue(exhaustion.isCancelled()); assertTrue(foodHealing.isCancelled());
|
||||
assertFalse(potionHealing.isCancelled(), "Explicit native magic healing remains available");
|
||||
assertFalse(abilityHealing.isCancelled(), "Explicit ability healing remains available");
|
||||
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
|
||||
|
||||
var ordinaryDrain = new org.bukkit.event.entity.FoodLevelChangeEvent(ordinary.player, 7);
|
||||
var ordinaryHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(ordinary.player, 1,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
|
||||
f.dispatcher.accept(ordinaryDrain); f.dispatcher.accept(ordinaryHealing);
|
||||
assertFalse(ordinaryDrain.isCancelled()); assertFalse(ordinaryHealing.isCancelled());
|
||||
assertEquals(8, ordinary.player.getFoodLevel()); assertEquals(4, ordinary.player.getExhaustion());
|
||||
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
|
||||
assertEquals(3, a.player.getExhaustion());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,61 @@ class CharacterStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void combatAwardsAtomicallyPersistXpPointsAndPerWorldRestrictedKillHistory() 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 live = new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);
|
||||
var worldA = UUID.randomUUID(); var worldB = UUID.randomUUID();
|
||||
var first = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
|
||||
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
|
||||
var near = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
|
||||
java.math.BigDecimal.valueOf(2), java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
|
||||
var farFromUpdated = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
|
||||
java.math.BigDecimal.valueOf(5), java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
storage.create(live).join();
|
||||
var applied = storage.awardCombatExperience(live.id(), live.revision(), BigInteger.TEN,
|
||||
progression, rewards, first).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, applied.status());
|
||||
assertEquals(1, applied.levels());
|
||||
assertEquals(first, applied.record().orElseThrow().restrictedKills().get(worldA));
|
||||
|
||||
var blocked = storage.awardCombatExperience(live.id(), applied.record().orElseThrow().revision(), BigInteger.TEN,
|
||||
progression, rewards, near).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.SPACING_BLOCKED, blocked.status());
|
||||
assertEquals(applied.record().orElseThrow().experience(), blocked.record().orElseThrow().experience());
|
||||
assertEquals(near, blocked.record().orElseThrow().restrictedKills().get(worldA), "Blocked kills still update history");
|
||||
|
||||
var second = storage.awardCombatExperience(live.id(), blocked.record().orElseThrow().revision(), BigInteger.TEN,
|
||||
progression, rewards, farFromUpdated).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, second.status());
|
||||
assertEquals(BigInteger.valueOf(20), second.record().orElseThrow().experience());
|
||||
var otherWorld = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldB,
|
||||
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
|
||||
var firstThere = storage.awardCombatExperience(live.id(), second.record().orElseThrow().revision(), BigInteger.TEN,
|
||||
progression, rewards, otherWorld).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, firstThere.status());
|
||||
assertEquals(2, firstThere.record().orElseThrow().restrictedKills().size());
|
||||
|
||||
var passive = storage.awardCombatExperience(live.id(), firstThere.record().orElseThrow().revision(), BigInteger.ONE,
|
||||
progression, rewards, null).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, passive.status());
|
||||
assertEquals(firstThere.record().orElseThrow().restrictedKills(), passive.record().orElseThrow().restrictedKills());
|
||||
var lowered = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.valueOf(1000), 1);
|
||||
var thirdWorld = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(UUID.randomUUID(),
|
||||
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
|
||||
var cappedHistory = storage.awardCombatExperience(live.id(), passive.record().orElseThrow().revision(), BigInteger.TEN,
|
||||
lowered, rewards, thirdWorld).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.AT_CAP, cappedHistory.status());
|
||||
assertEquals(passive.record().orElseThrow().experience(), cappedHistory.record().orElseThrow().experience());
|
||||
assertEquals(passive.record().orElseThrow().level(), cappedHistory.record().orElseThrow().level());
|
||||
assertEquals(thirdWorld, cappedHistory.record().orElseThrow().restrictedKills().get(thirdWorld.world()));
|
||||
assertEquals(cappedHistory.record().orElseThrow(), repository.load(live.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRevivalSaveLeavesDeadStateRetryableAndShutdownRejectsNewWork() throws Exception {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
|
||||
@@ -43,6 +43,29 @@ class YamlCharacterRepositoryTest {
|
||||
original.loadout(), original.cooldowns(), original.dead(), original.deathId(), original.diedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restrictedKillHistoryRoundTripsPerWorldAndOldRecordsRemainCompatible() throws Exception {
|
||||
var original = sample(UUID.randomUUID());
|
||||
var worldA = UUID.randomUUID(); var worldB = UUID.randomUUID();
|
||||
var history = Map.of(
|
||||
worldA, new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
|
||||
new BigDecimal("1.25"), new BigDecimal("-64"), new BigDecimal("3E+2")),
|
||||
worldB, new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldB,
|
||||
BigDecimal.ZERO, BigDecimal.ONE, BigDecimal.TEN));
|
||||
var withHistory = new CharacterRecord(original.id(), original.name(), original.revision(), original.stats(),
|
||||
original.level(), original.experience(), original.unspentPoints(), original.health(), original.learned(),
|
||||
original.loadout(), original.cooldowns(), history, original.dead(), original.deathId(), original.diedAt());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(withHistory);
|
||||
assertEquals(withHistory, repository.load(withHistory.id()).orElseThrow());
|
||||
String yaml = Files.readString(data.resolve("characters").resolve(withHistory.id() + ".yml"));
|
||||
assertTrue(yaml.contains("restricted-kills:"));
|
||||
assertTrue(yaml.contains(worldA.toString()));
|
||||
|
||||
String legacy = CharacterYaml.encode(original).replace("restricted-kills: []\n", "");
|
||||
assertEquals(Map.of(), CharacterYaml.decode(legacy).restrictedKills());
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedCreationAndAbandonmentDoNotAccumulatePerCharacterCoordinationFiles() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CombatContributionsTest {
|
||||
@Test void deduplicatesCharactersRefreshesTheirDeadlineAndConsumesATargetOnce() {
|
||||
var ledger = new CombatContributions(CombatRewardRules.defaults());
|
||||
var target = UUID.randomUUID(); var a = UUID.randomUUID(); var b = UUID.randomUUID();
|
||||
var start = Instant.parse("2026-09-19T20:00:00Z");
|
||||
ledger.accept(target, a, start);
|
||||
ledger.accept(target, b, start);
|
||||
ledger.accept(target, a, start.plusSeconds(60));
|
||||
assertEquals(Set.of(a), ledger.consume(target, start.plusSeconds(301)), "Repeated accepted damage refreshes only its character");
|
||||
assertEquals(Set.of(), ledger.consume(target, start.plusSeconds(301)), "One target is awarded once");
|
||||
}
|
||||
|
||||
@Test void invalidationAndCloseBoundMemoryAndRefuseLaterDamage() {
|
||||
var ledger = new CombatContributions(CombatRewardRules.defaults());
|
||||
var target = UUID.randomUUID(); var character = UUID.randomUUID();
|
||||
var now = Instant.parse("2026-09-19T20:00:00Z");
|
||||
ledger.accept(target, character, now); ledger.invalidate(target);
|
||||
assertEquals(Set.of(), ledger.consume(target, now));
|
||||
ledger.accept(target, character, now); ledger.close(); ledger.accept(target, character, now);
|
||||
assertEquals(Set.of(), ledger.consume(target, now));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CombatRewardRulesTest {
|
||||
@Test void poolsAndRoundedSharesPreserveApprovedArithmetic() {
|
||||
var rules = new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), new BigDecimal("3"));
|
||||
assertEquals(BigInteger.ONE, rules.pool(CombatRewardRules.Target.PASSIVE, 99));
|
||||
assertEquals(BigInteger.TEN, rules.pool(CombatRewardRules.Target.ORDINARY, 99));
|
||||
assertEquals(BigInteger.valueOf(100), rules.pool(CombatRewardRules.Target.BOSS, 99));
|
||||
assertEquals(BigInteger.valueOf(70), rules.pool(CombatRewardRules.Target.FANTASY_CHARACTER, 7));
|
||||
assertEquals(BigInteger.valueOf(4), rules.share(BigInteger.TEN, 3));
|
||||
assertEquals(BigInteger.ONE, rules.share(BigInteger.ONE, 3));
|
||||
assertEquals(BigInteger.valueOf(34), rules.share(BigInteger.valueOf(100), 3));
|
||||
assertEquals(BigInteger.valueOf(24), rules.share(BigInteger.valueOf(70), 3));
|
||||
}
|
||||
|
||||
@Test void contributionWindowIncludesExactFiveMinuteBoundaryAndRejectsFutureTimes() {
|
||||
var rules = CombatRewardRules.defaults();
|
||||
var hit = Instant.parse("2026-09-19T20:00:00Z");
|
||||
assertTrue(rules.contributes(hit, hit.plus(Duration.ofMinutes(5))));
|
||||
assertFalse(rules.contributes(hit, hit.plus(Duration.ofMinutes(5)).plusNanos(1)));
|
||||
assertFalse(rules.contributes(hit.plusSeconds(1), hit));
|
||||
}
|
||||
|
||||
@Test void restrictedSpacingIsPerWorldAndUsesStraightLineThreeDimensionalDistance() {
|
||||
var rules = CombatRewardRules.defaults();
|
||||
var a = UUID.randomUUID(); var b = UUID.randomUUID();
|
||||
var origin = new CombatRewardRules.KillPosition(a, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO);
|
||||
assertFalse(rules.spaced(origin, new CombatRewardRules.KillPosition(a, new BigDecimal("2.999"), BigDecimal.ZERO, BigDecimal.ZERO)));
|
||||
assertTrue(rules.spaced(origin, new CombatRewardRules.KillPosition(a, new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("2"))));
|
||||
assertTrue(rules.spaced(origin, new CombatRewardRules.KillPosition(b, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO)));
|
||||
}
|
||||
|
||||
@Test void rejectsInvalidRewardConfigurationAndArithmeticInputs() {
|
||||
assertAll(
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.valueOf(-1), BigInteger.TEN, BigInteger.TEN, BigDecimal.ONE)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.TEN, new BigDecimal("-0.1"))),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> CombatRewardRules.defaults().share(BigInteger.ONE, 0)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> CombatRewardRules.defaults().pool(CombatRewardRules.Target.FANTASY_CHARACTER, 0)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules.KillPosition(UUID.randomUUID(), new BigDecimal("1E1000000"), BigDecimal.ZERO, BigDecimal.ZERO))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user