Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba69a55124 | ||
|
|
db35c28f0b | ||
|
|
636ff9f076 | ||
|
|
7158817726 | ||
|
|
8ee0d98599 | ||
|
|
539900eb14 |
No files matched your search
@@ -2,6 +2,8 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore: [main]
|
||||
tags-ignore: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -21,7 +21,7 @@ import java.time.Clock;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Crafting, native fantasy sessions, books, parties and names; progression actions and spells remain separate. */
|
||||
/** Crafting, native fantasy sessions, books, parties, names and permanent-stat progression. */
|
||||
public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private CharacterStorage characters;
|
||||
private ManualCraftingController crafting;
|
||||
@@ -44,6 +44,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.session.AdventureModeLease gameModes;
|
||||
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() {
|
||||
@@ -67,9 +70,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var adventure = games.dmg.fantasystick.persistence.AdventureSettings.validate(getConfig().get("adventure-mode"));
|
||||
adventureSettings = new games.dmg.fantasystick.persistence.AdventureSettings(getDataFolder().toPath(), adventure);
|
||||
var switchDelay = switchDelay();
|
||||
var defaults = ProgressionRules.defaults();
|
||||
var rules = new ProgressionRules(new java.math.BigInteger(getConfig().getString("starting-points", "40")),
|
||||
defaults.xpPerLevel(), defaults.maximumLevel());
|
||||
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),
|
||||
@@ -110,20 +112,29 @@ 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);
|
||||
books = new games.dmg.fantasystick.book.CharacterBookController(characters, rules, nextTick,
|
||||
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 -> {
|
||||
String name = localPresentation.get().name(id);
|
||||
var player = getServer().getPlayer(id);
|
||||
return net.kyori.adventure.text.Component.text(name != null ? name : player == null ? id.toString() : player.getName());
|
||||
});
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, books);
|
||||
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
|
||||
basicAttack = localBasicAttack;
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, input -> {
|
||||
books.accept(input);
|
||||
localBasicAttack.accept(input);
|
||||
});
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings);
|
||||
@@ -138,6 +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,
|
||||
@@ -175,6 +190,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingModes = gameModes; gameModes = null;
|
||||
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);
|
||||
@@ -184,6 +202,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
cleanup("configuration", closingSettings::close);
|
||||
}
|
||||
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);
|
||||
@@ -195,6 +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);
|
||||
@@ -225,6 +249,29 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private ProgressionRules progressionRules() {
|
||||
try {
|
||||
var starting = new java.math.BigInteger(getConfig().getString("starting-points", "40"));
|
||||
var threshold = new java.math.BigInteger(getConfig().getString("xp-per-level", "100"));
|
||||
var maximum = new java.math.BigInteger(getConfig().getString("maximum-level", "30")).intValueExact();
|
||||
return new ProgressionRules(starting, threshold, maximum);
|
||||
} catch (ArithmeticException invalid) {
|
||||
throw new IllegalArgumentException("maximum-level exceeds the supported integer range", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -16,11 +16,12 @@ import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BookMeta;
|
||||
|
||||
/** Authoritative inspection and validated party controls; character mutation actions remain unfinished. */
|
||||
/** Authoritative inspection, permanent-stat purchases and validated party controls. */
|
||||
public final class CharacterBookController implements Consumer<ControlInput>, AutoCloseable, org.bukkit.event.Listener {
|
||||
public record Failure(UUID player, UUID character, Throwable cause) { }
|
||||
private final CharacterStorage characters;
|
||||
private final ProgressionRules rules;
|
||||
private final games.dmg.fantasystick.session.EquipmentSessions sessions;
|
||||
private final PartyBookControls partyControls;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> failures;
|
||||
@@ -28,16 +29,22 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private volatile boolean closed;
|
||||
private static final int VOLUME_SIZE = 80; // Leave room for navigation and the bounded 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 STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
|
||||
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
|
||||
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 record View(String nonce, ControlInput input, int previous, int next) { }
|
||||
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
|
||||
private final Map<UUID, View> views = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, StatView> statViews = new ConcurrentHashMap<>();
|
||||
|
||||
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
games.dmg.fantasystick.session.EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
|
||||
org.bukkit.Server server, java.util.function.Function<UUID, Component> names) {
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.rules = Objects.requireNonNull(rules);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
|
||||
@@ -51,6 +58,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
var owner = input.session().player();
|
||||
partyControls.forget(owner);
|
||||
views.remove(owner);
|
||||
statViews.remove(owner);
|
||||
Object ticket = new Object();
|
||||
pending.put(owner, ticket);
|
||||
characters.find(input.session().character()).whenComplete((record, failure) -> {
|
||||
@@ -96,6 +104,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
int start = Math.min(offset, (all.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
|
||||
int end = Math.min(start + VOLUME_SIZE, all.size());
|
||||
var pages = new java.util.ArrayList<Component>(all.subList(start, end));
|
||||
var statView = new StatView(UUID.randomUUID().toString(), input, record.revision());
|
||||
statViews.put(input.session().player(), statView);
|
||||
Component purchases = Component.text("Spend 1 point\n\n");
|
||||
for (var stat : Stat.values()) {
|
||||
purchases = purchases.append(Component.text("[+1 " + stat + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(STATS,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + statView.nonce() + ":" + stat + "\""))))
|
||||
.append(Component.newline());
|
||||
}
|
||||
pages.add(purchases);
|
||||
if (all.size() > VOLUME_SIZE) {
|
||||
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
|
||||
end < all.size() ? end : -1);
|
||||
@@ -139,6 +157,63 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void statAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
if (closed || !STATS.equals(event.getIdentifier())
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
|
||||
var player = connection.getPlayer();
|
||||
var view = statViews.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player || event.getTag() == null) return;
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 80) return;
|
||||
var match = STAT_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))
|
||||
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
var stat = Stat.valueOf(match.group(2));
|
||||
var currentHealth = java.math.BigDecimal.valueOf(player.getHealth());
|
||||
var nativeLimit = NativeHealthLimit.read();
|
||||
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
|
||||
.whenComplete((purchase, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> finishPurchase(view, stat, purchase, failure));
|
||||
} catch (RuntimeException rejected) { report(view.input(), rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void finishPurchase(StatView view, Stat stat, CharacterStorage.StatPurchase purchase, Throwable failure) {
|
||||
var input = view.input();
|
||||
if (closed || !input.stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
report(input, failure);
|
||||
input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
|
||||
return;
|
||||
}
|
||||
if (purchase.status() == CharacterStorage.StatPurchaseStatus.APPLIED) {
|
||||
var saved = purchase.record().orElseThrow();
|
||||
try {
|
||||
if (!sessions.acknowledgeProgress(input.session(), view.revision(), saved)) {
|
||||
input.player().sendMessage("Stat purchase was saved; reopen the character after the session transition.");
|
||||
return;
|
||||
}
|
||||
} catch (RuntimeException nativeFailure) {
|
||||
report(input, nativeFailure);
|
||||
input.player().sendMessage("Stat purchase was saved, but native health refresh failed; exit for recovery.");
|
||||
return;
|
||||
}
|
||||
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
|
||||
load(input, 0);
|
||||
return;
|
||||
}
|
||||
String message = switch (purchase.status()) {
|
||||
case STALE -> "That character page is stale; reopen it before spending points.";
|
||||
case NO_POINTS -> "This character has no unspent points.";
|
||||
case UNAVAILABLE -> "This character is no longer available.";
|
||||
case APPLIED -> throw new IllegalStateException("Handled applied purchase");
|
||||
};
|
||||
input.player().sendMessage(message);
|
||||
load(input, 0);
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void partyAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { partyControls.click(event); }
|
||||
|
||||
@@ -147,8 +222,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
var owner = event.getPlayer().getUniqueId();
|
||||
pending.remove(owner);
|
||||
views.remove(owner);
|
||||
statViews.remove(owner);
|
||||
partyControls.forget(owner);
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; pending.clear(); views.clear(); partyControls.close(); }
|
||||
@Override public void close() { closed = true; pending.clear(); views.clear(); statViews.clear(); partyControls.close(); }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package games.dmg.fantasystick.combat;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
|
||||
/** Server-thread built-in attack consumer; the bound stick's vanilla attack is always contained upstream. */
|
||||
public final class BasicAttack implements Consumer<ControlInput>, Listener, AutoCloseable {
|
||||
private static final Duration CADENCE = Duration.ofSeconds(1);
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock;
|
||||
private final Map<UUID, Instant> readyAt = new HashMap<>();
|
||||
private Pending pending;
|
||||
private boolean closed;
|
||||
|
||||
private static final class Pending {
|
||||
final ControlInput input;
|
||||
final LivingEntity target;
|
||||
final Instant attackedAt;
|
||||
boolean accepted;
|
||||
Pending(ControlInput input, LivingEntity target, Instant attackedAt) {
|
||||
this.input = input; this.target = target; this.attackedAt = attackedAt;
|
||||
}
|
||||
}
|
||||
|
||||
public BasicAttack(EquipmentSessions sessions) {
|
||||
this(sessions, Clock.systemUTC());
|
||||
}
|
||||
|
||||
public BasicAttack(EquipmentSessions sessions, Clock clock) {
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) {
|
||||
if (closed || pending != null || input.kind() != ControlInput.Kind.BASIC_ATTACK
|
||||
|| input.gesture() != ControlInput.Gesture.ATTACK
|
||||
|| !(input.target() instanceof LivingEntity target) || !input.stillCurrent()) return;
|
||||
var record = sessions.currentRecord(input.session()).orElse(null);
|
||||
if (record == null) return;
|
||||
Instant now = clock.instant();
|
||||
Instant ready = readyAt.get(input.session().character());
|
||||
if (ready != null && now.isBefore(ready)) return;
|
||||
BigInteger damage = damage(record.stats().get(Stat.STR));
|
||||
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
|
||||
var attempt = new Pending(input, target, now);
|
||||
pending = attempt;
|
||||
try {
|
||||
target.damage(nativeDamage, input.player());
|
||||
} finally {
|
||||
if (pending == attempt) pending = null;
|
||||
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
|
||||
}
|
||||
}
|
||||
|
||||
static BigInteger damage(BigInteger strength) {
|
||||
Objects.requireNonNull(strength);
|
||||
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
|
||||
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).max(BigInteger.ONE);
|
||||
}
|
||||
|
||||
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = pending;
|
||||
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|
||||
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
|
||||
attempt.accepted = true;
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
pending = null;
|
||||
readyAt.clear();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
@@ -204,12 +206,139 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
public enum ExperienceAwardStatus { APPLIED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
|
||||
public record ExperienceAward(ExperienceAwardStatus status, Optional<CharacterRecord> record, int levels) { }
|
||||
|
||||
/** Persist configured transferable XP/level/point progression as one expected revision. */
|
||||
public CompletableFuture<ExperienceAward> awardExperience(UUID characterId, BigInteger expectedRevision,
|
||||
BigInteger reward, ProgressionRules rules) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(expectedRevision);
|
||||
Objects.requireNonNull(reward);
|
||||
Objects.requireNonNull(rules);
|
||||
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 ExperienceAward(ExperienceAwardStatus.UNAVAILABLE, Optional.empty(), 0);
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) {
|
||||
return new ExperienceAward(ExperienceAwardStatus.STALE, Optional.of(old), 0);
|
||||
}
|
||||
var cap = rules.xpPerLevel().multiply(BigInteger.valueOf(rules.maximumLevel() - 1L));
|
||||
if (old.level() >= rules.maximumLevel() || old.experience().compareTo(cap) >= 0) {
|
||||
return new ExperienceAward(ExperienceAwardStatus.AT_CAP, Optional.of(old), 0);
|
||||
}
|
||||
var experience = old.experience().add(reward).min(cap);
|
||||
if (experience.equals(old.experience())) {
|
||||
var status = old.level() >= rules.maximumLevel()
|
||||
? ExperienceAwardStatus.AT_CAP : ExperienceAwardStatus.UNCHANGED;
|
||||
return new ExperienceAward(status, Optional.of(old), 0);
|
||||
}
|
||||
int level = experience.divide(rules.xpPerLevel()).intValueExact() + 1;
|
||||
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(), 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) { }
|
||||
|
||||
/** Persist one active-session stat purchase and its current native health as one revision. */
|
||||
public CompletableFuture<StatPurchase> purchaseStat(UUID characterId, BigInteger expectedRevision, Stat stat,
|
||||
java.math.BigDecimal currentHealth,
|
||||
java.math.BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(expectedRevision);
|
||||
Objects.requireNonNull(stat);
|
||||
Objects.requireNonNull(currentHealth);
|
||||
Objects.requireNonNull(nativeLimit);
|
||||
if (currentHealth.signum() < 0 || nativeLimit.signum() <= 0) {
|
||||
throw new IllegalArgumentException("Invalid stat-purchase health bound");
|
||||
}
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new StatPurchase(StatPurchaseStatus.UNAVAILABLE, Optional.empty());
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) {
|
||||
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
|
||||
}
|
||||
if (old.unspentPoints().signum() == 0) {
|
||||
return new StatPurchase(StatPurchaseStatus.NO_POINTS, Optional.of(old));
|
||||
}
|
||||
var oldMaximum = old.maximumHealth().min(nativeLimit);
|
||||
if (currentHealth.compareTo(oldMaximum) > 0) {
|
||||
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
|
||||
}
|
||||
var stats = new java.util.EnumMap<Stat, BigInteger>(Stat.class);
|
||||
stats.putAll(old.stats());
|
||||
stats.put(stat, stats.get(stat).add(BigInteger.ONE));
|
||||
var rawMaximum = new java.math.BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN));
|
||||
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(), old.restrictedKills(), false,
|
||||
old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
/** Session-owned remaining health; retain all other current fields and reject concurrent revision changes. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, java.math.BigDecimal health,
|
||||
java.math.BigDecimal nativeLimit) {
|
||||
@@ -223,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);
|
||||
});
|
||||
@@ -254,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);
|
||||
});
|
||||
@@ -274,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;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import games.dmg.fantasystick.items.SessionControls;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
@@ -201,6 +202,34 @@ 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());
|
||||
return current(session) && attempt != null && attempt.record != null
|
||||
? Optional.of(attempt.record) : Optional.empty();
|
||||
}
|
||||
|
||||
/** Apply a persisted progression revision to the still-current active session. */
|
||||
public synchronized boolean acknowledgeProgress(Active session, BigInteger expectedRevision, CharacterRecord saved) {
|
||||
var attempt = active.get(session.player());
|
||||
if (closed || attempt == null || attempt.session == null || attempt.record == null
|
||||
|| !attempt.session.equals(session.session()) || !attempt.character.equals(session.character())
|
||||
|| !attempt.record.revision().equals(expectedRevision) || !saved.id().equals(attempt.character)
|
||||
|| !saved.revision().equals(expectedRevision.add(BigInteger.ONE)) || !current(session)) return false;
|
||||
var previous = attempt.record;
|
||||
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
|
||||
attempt.record = saved;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Server-thread authorization for the currently held, issued control and saved loadout. */
|
||||
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
|
||||
var attempt = active.get(player.getUniqueId());
|
||||
|
||||
@@ -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
|
||||
@@ -69,6 +74,30 @@ public final class SessionVitals implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply an acknowledged stat revision to the live native health pool exactly once. */
|
||||
public void updateProgress(Player player, UUID session, CharacterRecord previous, CharacterRecord saved) {
|
||||
Objects.requireNonNull(previous);
|
||||
Objects.requireNonNull(saved);
|
||||
var mark = read(player);
|
||||
var lease = leases.get(player.getUniqueId());
|
||||
if (closed || mark == null || mark.phase() != Phase.APPLIED || !mark.session().equals(session)
|
||||
|| lease == null || !lease.session().equals(session) || !previous.id().equals(saved.id())
|
||||
|| !previous.revision().add(java.math.BigInteger.ONE).equals(saved.revision())) {
|
||||
throw new IllegalStateException("No matching active fantasy vitals revision");
|
||||
}
|
||||
double nextMaximum = saved.maximumHealth().min(NativeHealthLimit.read()).doubleValue();
|
||||
double gain = Math.max(0, nextMaximum - mark.maximum());
|
||||
if (gain == 0 && nextMaximum == mark.maximum()) return;
|
||||
var nativeAttribute = Objects.requireNonNull(((CraftPlayer) player).getHandle()
|
||||
.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
|
||||
lease.maximum().close();
|
||||
var replacement = new NativeMaximumHealth(nativeAttribute, nextMaximum);
|
||||
leases.put(player.getUniqueId(), new Lease(session, replacement));
|
||||
player.setHealth(Math.min(nextMaximum, player.getHealth() + gain));
|
||||
player.setHealthScale(nextMaximum);
|
||||
write(player, new Mark(session, Phase.APPLIED, nextMaximum, mark.ordinary()));
|
||||
}
|
||||
|
||||
public BigDecimal remaining(Player player, UUID session, OrdinaryVitals ordinary) {
|
||||
var mark = read(player);
|
||||
if (mark == null || !mark.session().equals(session) || !mark.ordinary().equals(ordinary) || mark.phase() != Phase.APPLIED) {
|
||||
@@ -97,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;
|
||||
@@ -111,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());
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
# Captured when a new fantasy session starts; changing this does not alter active sessions.
|
||||
# Admin command: /fantasystick config adventure-mode [true|false]
|
||||
adventure-mode: true
|
||||
|
||||
# New-character and transferable fantasy progression defaults.
|
||||
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,114 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class BasicAttackNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void successfulHitsStartAnExactOneSecondCadenceButCancelledDamageDoesNot() 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); f.activate(a);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(a.core.id).orElseThrow();
|
||||
var clock = new MutableClock(Instant.parse("2026-09-19T00:00:00Z"));
|
||||
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
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 org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
|
||||
event.setCancelled(cancelled.get());
|
||||
basic.acceptedDamage(event);
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), same(a.player));
|
||||
var input = new games.dmg.fantasystick.session.ControlInput(a.player, active, 1, "",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1,
|
||||
() -> sessions.current(active));
|
||||
basic.accept(input); // Cancelled: no cadence spent.
|
||||
cancelled.set(false);
|
||||
basic.accept(input); // Accepted at t=0.
|
||||
clock.advance(java.time.Duration.ofMillis(999)); basic.accept(input);
|
||||
verify(target, times(2)).damage(1.0, a.player);
|
||||
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
|
||||
verify(target, times(3)).damage(1.0, a.player);
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
|
||||
verifyNoMoreInteractions(target);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partyCancellationPreventsDamageAndDoesNotSpendThePackagedAttackCadence() 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);
|
||||
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 events = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
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 org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, b.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
|
||||
events.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(b.player).damage(anyDouble(), same(a.player));
|
||||
a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
assertEquals(1, events.size()); assertTrue(events.getFirst().isCancelled());
|
||||
assertTrue(parties.leave(a.core.id, party.id()));
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
assertEquals(2, events.size()); assertFalse(events.getLast().isCancelled());
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
assertEquals(2, events.size(), "The accepted second hit starts cadence; the cancelled first hit did not");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedBasicStickInputDealsOneLowStrengthHitWithoutVanillaContinuation() 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); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(attack);
|
||||
assertTrue(attack.isCancelled(), "The icon stick must never continue into vanilla damage");
|
||||
verify(target).damage(1.0, a.player);
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
|
||||
assertTrue(a.core.inventory.getItem(1) == null || a.core.inventory.getItem(1).isEmpty(),
|
||||
"Exit restores the ordinary item that occupied the basic-stick slot");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MutableClock extends Clock {
|
||||
private Instant now;
|
||||
MutableClock(Instant now) { this.now = now; }
|
||||
void advance(java.time.Duration duration) { now = now.plus(duration); }
|
||||
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
|
||||
@Override public Clock withZone(ZoneId zone) { return this; }
|
||||
@Override public Instant instant() { return now; }
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,103 @@ class CharacterBookNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void bookPurchasePersistsConAndPointsAndAppliesTheAcknowledgedNativeHealthGain() 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); f.activate(a);
|
||||
a.player.setHealth(5);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
var purchase = PartyBookNativeTest.button(a, "[+1 CON]");
|
||||
PartyBookNativeTest.click(f, a, purchase);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.CON));
|
||||
assertEquals(java.math.BigInteger.valueOf(39), saved.unspentPoints());
|
||||
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(6)));
|
||||
assertEquals(11, a.maximumHealth.getValue());
|
||||
assertEquals(6, a.player.getHealth());
|
||||
assertTrue(PartyBookNativeTest.text(a).contains("CON: 1"));
|
||||
assertTrue(PartyBookNativeTest.text(a).contains("Unspent points: 39"));
|
||||
PartyBookNativeTest.click(f, a, purchase);
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(saved, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(),
|
||||
"A rendered purchase action is single-use");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void conPurchaseKeepsItsOwnMaximumAndOneTimeGainDespiteExternalModifiers() 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);
|
||||
var before = new net.minecraft.world.entity.ai.attributes.AttributeModifier(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath("test", "before_purchase"), 5,
|
||||
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
|
||||
a.maximumHealth.addTransientModifier(before);
|
||||
f.activate(a); assertEquals(10, a.maximumHealth.getValue());
|
||||
var during = new net.minecraft.world.entity.ai.attributes.AttributeModifier(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath("test", "during_purchase"), 100,
|
||||
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
|
||||
a.maximumHealth.addPermanentModifier(during);
|
||||
assertEquals(10, a.maximumHealth.getValue());
|
||||
a.player.setHealth(5);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 CON]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(11, a.maximumHealth.getValue()); assertEquals(6, a.player.getHealth());
|
||||
assertEquals(java.util.Set.of(before, during), a.maximumHealth.getModifiers());
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(java.util.Set.of(before, during), a.maximumHealth.getModifiers());
|
||||
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.CON));
|
||||
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(6)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedPurchaseChangesNothingAndAcknowledgedStatsSurviveReopenTransferAndReload() 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);
|
||||
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);
|
||||
a.player.setHealth(5);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
var rejected = PartyBookNativeTest.button(a, "[+1 STR]");
|
||||
var before = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"), java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
PartyBookNativeTest.click(f, a, rejected);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
}
|
||||
assertEquals(before, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
assertEquals(5, a.player.getHealth()); assertEquals(10, a.maximumHealth.getValue());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
|
||||
PartyBookNativeTest.click(f, a, rejected); PartyBookNativeTest.drain(f);
|
||||
assertEquals(before, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
|
||||
PartyBookNativeTest.open(f, a);
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 STR]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.STR));
|
||||
assertEquals(java.math.BigInteger.valueOf(39), saved.unspentPoints());
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
|
||||
PartyBookNativeTest.open(f, b);
|
||||
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
|
||||
assertTrue(PartyBookNativeTest.text(b).contains("Unspent points: 39"));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
f.players.clear(); f.plugin.setEnabled(true); f.players.put(b.core.id, b.player); f.reenter(b);
|
||||
PartyBookNativeTest.open(f, b);
|
||||
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
|
||||
assertEquals(saved.stats(), characters(f).find(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow().stats());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativePagesKeepLargeNumbersUnicodeNamesAndUnknownAbilitiesReadableAndComplete() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -166,7 +263,7 @@ class CharacterBookNativeTest {
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a);
|
||||
click(f, a); drainReads(f); // Old holder has a queued result, not a delivered book.
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
var renamed = characters(f).renameCharacter(a.core.record.id(), "Traded 雪 character").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
runQueued(f); assertTrue(a.books.isEmpty());
|
||||
b.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamed));
|
||||
|
||||
@@ -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,9 +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());
|
||||
@@ -128,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,
|
||||
@@ -206,6 +212,21 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidExperienceThresholdAndMaximumLevelFailBeforeInstallingGameplay() throws Exception {
|
||||
for (String configuration : List.of("xp-per-level: 0\n", "xp-per-level: -1\n",
|
||||
"maximum-level: 0\n", "maximum-level: 2147483648\n", "maximum-level: not-a-number\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(configuration.hashCode()));
|
||||
java.nio.file.Files.createDirectories(directory);
|
||||
java.nio.file.Files.writeString(directory.resolve("config.yml"), configuration);
|
||||
try (var fixture = new Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true));
|
||||
assertTrue(fixture.order.isEmpty());
|
||||
assertTrue(fixture.recipes.isEmpty());
|
||||
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void installedGuardRejectsOnlyTheCharacterRecipeThroughTheNativeDispatcher() throws Exception {
|
||||
try (var fixture = new Fixture(data)) {
|
||||
@@ -721,6 +742,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertNull(saved.pending()); assertNull(saved.lastExit());
|
||||
} else {
|
||||
f.activate(a); sessions.exit(a.player);
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
|
||||
var saved = f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow();
|
||||
assertNotNull(saved.pending()); assertNotNull(saved.lastExit());
|
||||
@@ -790,7 +812,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
|
||||
sessions.exit(a.player);
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get();
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
var b = new Actor(data); f.players.put(b.core.id, b.player);
|
||||
b.core.gameMode = org.bukkit.GameMode.SURVIVAL;
|
||||
f.activate(b);
|
||||
@@ -807,27 +829,58 @@ class FantasyStickLifecycleNativeTest {
|
||||
if (!value.equals("default")) java.nio.file.Files.writeString(directory.resolve("config.yml"), "character-swap-cooldown-seconds: " + value + "\n");
|
||||
try (var f = new Fixture(directory)) {
|
||||
f.plugin.setEnabled(true); var a = new Actor(directory); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
var exit = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
assertEquals(java.time.Duration.ofSeconds(value.equals("default") ? 900 : Long.parseLong(value)),
|
||||
java.time.Duration.between(exit.exitedAt(), exit.deadline()));
|
||||
assertEquals(a.core.record.id(), exit.characterId());
|
||||
var other = new Actor(directory);
|
||||
a.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand().clone());
|
||||
var before = a.core.snapshot();
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
f.tick(); f.tick(); // Join receipt reconciliation, then actual coordinator eligibility.
|
||||
if (value.equals("0")) {
|
||||
f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"), "Configured zero reaches entry, not only persisted arithmetic");
|
||||
} else {
|
||||
assertArrayEquals(before, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
|
||||
assertTrue(a.messages.getLast().matches("(?s).*Wait [1-9][0-9]* seconds.*same character may be resumed now.*"), a.messages::toString);
|
||||
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
|
||||
assertNull(state.pending()); assertEquals(exit, state.lastExit());
|
||||
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
|
||||
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
|
||||
var worker = (java.util.concurrent.ExecutorService) Fixture.field(characters, "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
worker.execute(() -> {
|
||||
entered.countDown();
|
||||
try { release.await(); }
|
||||
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
|
||||
});
|
||||
try {
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending(),
|
||||
"Quit restores native state but must retain ownership until health persistence completes");
|
||||
release.countDown();
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
var exit = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
assertEquals(java.time.Duration.ofSeconds(value.equals("default") ? 900 : Long.parseLong(value)),
|
||||
java.time.Duration.between(exit.exitedAt(), exit.deadline()));
|
||||
assertEquals(a.core.record.id(), exit.characterId());
|
||||
var other = new Actor(directory);
|
||||
a.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand().clone());
|
||||
var before = a.core.snapshot();
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
|
||||
}
|
||||
f.tick(); f.tick(); // Join receipt reconciliation, then actual coordinator eligibility.
|
||||
if (value.equals("0")) {
|
||||
f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"), "Configured zero reaches entry, not only persisted arithmetic");
|
||||
} else {
|
||||
assertArrayEquals(before, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
|
||||
assertTrue(a.messages.getLast().matches("(?s).*Wait [1-9][0-9]* seconds.*same character may be resumed now.*"), a.messages::toString);
|
||||
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
|
||||
assertNull(state.pending()); assertEquals(exit, state.lastExit());
|
||||
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
|
||||
}
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1144,6 +1197,12 @@ class FantasyStickLifecycleNativeTest {
|
||||
games.dmg.fantasystick.persistence.EquipmentSessionStore receipts() {
|
||||
return (games.dmg.fantasystick.persistence.EquipmentSessionStore) field(plugin, "receipts");
|
||||
}
|
||||
void awaitCharacterAndReceipt(Actor actor) throws Exception {
|
||||
// A receipt read alone cannot await work submitted later by the character-save callback.
|
||||
((CharacterStorage) field(plugin, "characters")).find(actor.core.record.id())
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
receipts().find(actor.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
}
|
||||
void tick() throws Exception {
|
||||
var task = tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(task, "Missing deferred main-thread callback"); task.run();
|
||||
@@ -1212,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(
|
||||
@@ -1220,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);
|
||||
@@ -1253,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;
|
||||
@@ -1274,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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,7 +234,7 @@ class PartyBookNativeTest {
|
||||
open(f, a); var oldDisband = button(a, "[Disband party]");
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
|
||||
open(f, b);
|
||||
assertTrue(text(b).contains(a.core.record.name()));
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package games.dmg.fantasystick.combat;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class BasicAttackTest {
|
||||
@Test void followsTheApprovedStrengthFormulaWithoutAStatCap() {
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.ZERO));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.TEN));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(11)));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.TWO, BasicAttack.damage(BigInteger.valueOf(14)));
|
||||
var huge = BigInteger.TEN.pow(1000);
|
||||
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO), BasicAttack.damage(huge));
|
||||
assertThrows(IllegalArgumentException.class, () -> BasicAttack.damage(BigInteger.valueOf(-1)));
|
||||
assertThrows(NullPointerException.class, () -> BasicAttack.damage(null));
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,90 @@ class CharacterStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void awardsConfiguredExperiencePersistentlyWithMultiLevelAndCapIdempotence() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var rules = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.valueOf(2), BigInteger.valueOf(7), 3);
|
||||
var live = new games.dmg.fantasystick.crafting.CharacterFactory().create(rules);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
storage.create(live).join();
|
||||
var awarded = storage.awardExperience(live.id(), live.revision(), BigInteger.valueOf(8), rules).join();
|
||||
assertEquals(CharacterStorage.ExperienceAwardStatus.APPLIED, awarded.status());
|
||||
assertEquals(1, awarded.levels());
|
||||
var levelTwo = awarded.record().orElseThrow();
|
||||
assertEquals(2, levelTwo.level());
|
||||
assertEquals(BigInteger.valueOf(8), levelTwo.experience());
|
||||
assertEquals(BigInteger.valueOf(3), levelTwo.unspentPoints());
|
||||
var capped = storage.awardExperience(live.id(), levelTwo.revision(), BigInteger.valueOf(100), rules).join();
|
||||
assertEquals(1, capped.levels());
|
||||
assertEquals(3, capped.record().orElseThrow().level());
|
||||
assertEquals(BigInteger.valueOf(14), capped.record().orElseThrow().experience());
|
||||
assertEquals(CharacterStorage.ExperienceAwardStatus.AT_CAP,
|
||||
storage.awardExperience(live.id(), capped.record().orElseThrow().revision(), BigInteger.ONE, rules).join().status());
|
||||
assertEquals(CharacterStorage.ExperienceAwardStatus.STALE,
|
||||
storage.awardExperience(live.id(), live.revision(), BigInteger.ONE, rules).join().status());
|
||||
var lowered = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.valueOf(7), 2);
|
||||
assertEquals(CharacterStorage.ExperienceAwardStatus.AT_CAP,
|
||||
storage.awardExperience(live.id(), capped.record().orElseThrow().revision(), BigInteger.ONE, lowered).join().status());
|
||||
assertEquals(capped.record().orElseThrow(), repository.load(live.id()).orElseThrow(),
|
||||
"Lowering configuration must not rewrite existing transferable progress");
|
||||
}
|
||||
}
|
||||
|
||||
@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