Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74e717c6ca | ||
|
|
644b343fc9 |
No files matched your search
@@ -46,6 +46,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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.ability.AbilityRegistry abilityRegistry;
|
||||
private games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
|
||||
private games.dmg.fantasystick.ability.Fireball fireball;
|
||||
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@@ -72,6 +75,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var switchDelay = switchDelay();
|
||||
var rules = progressionRules();
|
||||
var rewardRules = combatRewardRules();
|
||||
var fireballSettings = fireballSettings();
|
||||
var factory = new CharacterFactory();
|
||||
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
|
||||
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
|
||||
@@ -110,6 +114,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
gameModes = new games.dmg.fantasystick.session.AdventureModeLease();
|
||||
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
|
||||
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
|
||||
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
|
||||
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
|
||||
abilityRegistry.register(fireball.definition());
|
||||
sessions.attachAbilityRegistry(abilityRegistry);
|
||||
vitals = new games.dmg.fantasystick.session.SessionVitals();
|
||||
sessions.attachVitals(vitals);
|
||||
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
|
||||
@@ -122,19 +130,31 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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()));
|
||||
games.dmg.fantasystick.ability.AbilityScheduler abilityScheduler = (delay, action) -> {
|
||||
long ticks = Math.max(1, (delay.toNanos() + 49_999_999L) / 50_000_000L);
|
||||
var task = getServer().getScheduler().runTaskLater(this, action, ticks);
|
||||
return task::cancel;
|
||||
};
|
||||
abilityEngine = new games.dmg.fantasystick.ability.AbilityEngine(abilityRegistry, characters, sessions, parties,
|
||||
Clock.systemUTC(), nextTick, abilityScheduler, report -> { },
|
||||
problem -> getLogger().log(Level.WARNING, "Ability engine failed for character " + problem.character()
|
||||
+ "; ability " + problem.ability(), problem.cause()));
|
||||
abilityEngine.install(this);
|
||||
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());
|
||||
});
|
||||
}, abilityRegistry, abilityEngine);
|
||||
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
|
||||
basicAttack = localBasicAttack;
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, input -> {
|
||||
books.accept(input);
|
||||
localBasicAttack.accept(input);
|
||||
abilityEngine.accept(input);
|
||||
});
|
||||
sessionEvents.attachAbilityRegistry(abilityRegistry);
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings);
|
||||
@@ -151,6 +171,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
partyCombat.install(this);
|
||||
getServer().getPluginManager().registerEvents(combatExperience, this);
|
||||
combatExperience.install(this);
|
||||
getServer().getPluginManager().registerEvents(fireball, this);
|
||||
getServer().getPluginManager().registerEvents(localBasicAttack, this);
|
||||
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
@@ -192,6 +213,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingVisuals = sessionVisuals; sessionVisuals = null;
|
||||
var closingBasicAttack = basicAttack; basicAttack = null;
|
||||
var closingCombatExperience = combatExperience; combatExperience = null;
|
||||
var closingAbilityEngine = abilityEngine; abilityEngine = null;
|
||||
var closingFireball = fireball; fireball = null;
|
||||
abilityRegistry = null;
|
||||
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
|
||||
// Retire client resources independently of successful ordinary-equipment restoration.
|
||||
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
|
||||
@@ -204,6 +228,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingBooks != null) cleanup("character book", closingBooks::close);
|
||||
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
|
||||
if (closingCombatExperience != null) cleanup("combat experience", closingCombatExperience::close);
|
||||
if (closingFireball != null) cleanup("Fireball", closingFireball::close);
|
||||
if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close);
|
||||
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
|
||||
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
|
||||
if (closingParties != null) cleanup("parties", closingParties::close);
|
||||
@@ -217,6 +243,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingPartyEvents);
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingCombatExperience);
|
||||
unregister(closingFireball);
|
||||
unregister(closingBasicAttack);
|
||||
unregister(closingNutrition);
|
||||
unregister(closingBooks);
|
||||
@@ -272,6 +299,20 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.ability.Fireball.Settings fireballSettings() {
|
||||
try {
|
||||
var base = new java.math.BigInteger(getConfig().getString("fireball-base-damage", "6"));
|
||||
var cooldown = java.time.Duration.ofSeconds(new java.math.BigInteger(
|
||||
getConfig().getString("fireball-cooldown-seconds", "4")).longValueExact());
|
||||
double range = new java.math.BigDecimal(getConfig().getString("fireball-range-blocks", "32")).doubleValue();
|
||||
var lifetime = java.time.Duration.ofSeconds(new java.math.BigInteger(
|
||||
getConfig().getString("fireball-lifetime-seconds", "5")).longValueExact());
|
||||
return new games.dmg.fantasystick.ability.Fireball.Settings(base, cooldown, range, lifetime);
|
||||
} catch (ArithmeticException | NumberFormatException invalid) {
|
||||
throw new IllegalArgumentException("Fireball settings must use supported nonnegative numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private java.time.Duration switchDelay() {
|
||||
Object configured = getConfig().get("character-swap-cooldown-seconds", 900);
|
||||
String value = String.valueOf(configured);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import org.bukkit.Material;
|
||||
|
||||
/** Immutable internal ability metadata and focused effect boundary; not an external add-on API. */
|
||||
public record AbilityDefinition(String id, String name, Material icon, List<String> lore, String instructions,
|
||||
Map<Stat, BigInteger> requirements, BigInteger pointCost, Activation activation,
|
||||
ControlInput.Gesture gesture, Duration cooldown, Map<String, BigDecimal> parameters,
|
||||
TargetPolicy targetPolicy, AbilityEffect effect) {
|
||||
public enum Activation { IMMEDIATE, DELAYED, PROJECTILE, AREA, SELECTION }
|
||||
public enum TargetPolicy { SELF, HOSTILE, PROJECTILE_HOSTILE, ALLY, PARTY, AREA, SELECTION, EXCEPTIONAL_NONCOMBAT }
|
||||
private static final Pattern ID = Pattern.compile("[a-z][a-z0-9_]{0,63}");
|
||||
private static final Pattern PARAMETER = Pattern.compile("[a-z][a-z0-9_-]{0,63}");
|
||||
|
||||
public AbilityDefinition {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(icon);
|
||||
Objects.requireNonNull(lore); Objects.requireNonNull(instructions); Objects.requireNonNull(requirements);
|
||||
Objects.requireNonNull(pointCost); Objects.requireNonNull(activation); Objects.requireNonNull(gesture);
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(parameters); Objects.requireNonNull(targetPolicy);
|
||||
Objects.requireNonNull(effect);
|
||||
lore = List.copyOf(lore); requirements = Map.copyOf(requirements); parameters = Map.copyOf(parameters);
|
||||
if (!ID.matcher(id).matches() || name.isBlank() || icon.isAir() || lore.isEmpty()
|
||||
|| lore.stream().anyMatch(String::isBlank) || instructions.isBlank() || pointCost.signum() <= 0
|
||||
|| cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0 || requirements.values().stream().anyMatch(value -> value.compareTo(BigInteger.TEN) < 0)
|
||||
|| parameters.entrySet().stream().anyMatch(entry -> !PARAMETER.matcher(entry.getKey()).matches()
|
||||
|| entry.getValue().signum() < 0 || !Double.isFinite(entry.getValue().doubleValue()))) {
|
||||
throw new IllegalArgumentException("Invalid ability definition");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
/** Focused native effect invoked only after shared persistence, cooldown and target authorization. */
|
||||
@FunctionalInterface
|
||||
public interface AbilityEffect {
|
||||
void activate(AbilityExecution execution);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Shared character mutation, cooldown, target, execution, report and cleanup boundary. */
|
||||
public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseable {
|
||||
public record Failure(UUID character, String ability, Throwable cause) { }
|
||||
public record Mutation(CharacterStorage.AbilityMutationStatus status, CharacterRecord record) { }
|
||||
private record Key(UUID character, String ability) { }
|
||||
|
||||
private final AbilityRegistry registry;
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final AbilityScheduler scheduler;
|
||||
private final Consumer<AbilityReport> reports;
|
||||
private final Consumer<Failure> failures;
|
||||
private final Map<Key, Object> pending = new HashMap<>();
|
||||
private final Map<Key, Long> generations = new HashMap<>();
|
||||
private final Map<Key, Set<Runtime>> active = new HashMap<>();
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
|
||||
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
|
||||
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
|
||||
this.registry = Objects.requireNonNull(registry); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
|
||||
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.scheduler = Objects.requireNonNull(scheduler); this.reports = Objects.requireNonNull(reports);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
if (closed || sweepTask != null) throw new IllegalStateException("Ability engine is already installed or closed");
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
|
||||
public CompletableFuture<Mutation> purchase(ControlInput input, String ability, BigInteger expectedRevision) {
|
||||
Objects.requireNonNull(input); Objects.requireNonNull(expectedRevision);
|
||||
var definition = registry.find(ability).orElse(null);
|
||||
if (closed || definition == null || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
|
||||
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.AbilityMutationStatus.UNAVAILABLE, null));
|
||||
}
|
||||
return mutation(input, ability, expectedRevision,
|
||||
() -> characters.unlockAbility(input.session().character(), expectedRevision, ability,
|
||||
definition.requirements(), definition.pointCost()));
|
||||
}
|
||||
|
||||
public CompletableFuture<Mutation> assign(ControlInput input, String ability, int slot, BigInteger expectedRevision) {
|
||||
Objects.requireNonNull(input); Objects.requireNonNull(expectedRevision);
|
||||
if (closed || registry.find(ability).isEmpty() || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
|
||||
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.AbilityMutationStatus.UNAVAILABLE, null));
|
||||
}
|
||||
return mutation(input, ability, expectedRevision,
|
||||
() -> characters.assignAbility(input.session().character(), expectedRevision, ability, slot));
|
||||
}
|
||||
|
||||
private CompletableFuture<Mutation> mutation(ControlInput input, String ability, BigInteger expectedRevision,
|
||||
java.util.function.Supplier<CompletableFuture<CharacterStorage.AbilityMutation>> operation) {
|
||||
var result = new CompletableFuture<Mutation>();
|
||||
CompletableFuture<CharacterStorage.AbilityMutation> work;
|
||||
try { work = operation.get(); }
|
||||
catch (RuntimeException failure) { report(input.session().character(), ability, failure); result.completeExceptionally(failure); return result; }
|
||||
work.whenComplete((mutation, failure) -> schedule(input.session().character(), ability, result, () -> {
|
||||
if (failure != null) { report(input.session().character(), ability, failure); result.completeExceptionally(failure); return; }
|
||||
var record = mutation.record().orElse(null);
|
||||
if (mutation.status() == CharacterStorage.AbilityMutationStatus.APPLIED) {
|
||||
try {
|
||||
if (record == null || !input.stillCurrent()
|
||||
|| !sessions.acknowledgeProgress(input.session(), expectedRevision, record)) {
|
||||
result.complete(new Mutation(CharacterStorage.AbilityMutationStatus.STALE, record)); return;
|
||||
}
|
||||
} catch (RuntimeException nativeFailure) {
|
||||
report(input.session().character(), ability, nativeFailure);
|
||||
result.completeExceptionally(nativeFailure); return;
|
||||
}
|
||||
}
|
||||
result.complete(new Mutation(mutation.status(), record));
|
||||
}));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) {
|
||||
if (closed || input.kind() != ControlInput.Kind.ABILITY || !input.stillCurrent()) return;
|
||||
var definition = registry.find(input.ability()).orElse(null);
|
||||
if (definition == null || definition.gesture() != input.gesture()) {
|
||||
input.player().sendMessage("That ability is unavailable."); return;
|
||||
}
|
||||
var record = sessions.currentRecord(input.session()).orElse(null);
|
||||
if (record == null || !record.learned().contains(definition.id())
|
||||
|| input.slot() < 2 || !record.loadout().get(input.slot() - 2).equals(definition.id())) {
|
||||
input.player().sendMessage("That ability is not learned and assigned to this control."); return;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
Instant existing = record.cooldowns().get(definition.id());
|
||||
if (existing != null && now.isBefore(existing)) {
|
||||
input.player().sendMessage("" + definition.name() + " is ready in "
|
||||
+ Math.max(1, Duration.between(now, existing).toSeconds()) + " seconds."); return;
|
||||
}
|
||||
if (!eligible(definition.targetPolicy(), input, input.target())) {
|
||||
input.player().sendMessage("That target is not eligible for " + definition.name() + "."); return;
|
||||
}
|
||||
var key = new Key(input.session().character(), definition.id());
|
||||
Object token = new Object();
|
||||
if (pending.putIfAbsent(key, token) != null) { input.player().sendMessage("That cast is still being saved."); return; }
|
||||
long generation = generations.getOrDefault(key, 0L);
|
||||
Instant readyAt;
|
||||
try { readyAt = now.plus(definition.cooldown()); }
|
||||
catch (DateTimeException | ArithmeticException invalid) {
|
||||
pending.remove(key, token); report(key.character(), key.ability(), invalid);
|
||||
input.player().sendMessage("That ability's cooldown is invalid."); return;
|
||||
}
|
||||
characters.commitAbilityCooldown(key.character(), record.revision(), key.ability(), readyAt)
|
||||
.whenComplete((mutation, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> completeCast(input, definition, record.revision(), readyAt,
|
||||
key, token, generation, mutation, failure));
|
||||
} catch (RuntimeException rejected) {
|
||||
pending.remove(key, token); report(key.character(), key.ability(), rejected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void completeCast(ControlInput input, AbilityDefinition definition, BigInteger expectedRevision, Instant readyAt,
|
||||
Key key, Object token, long generation, CharacterStorage.AbilityMutation mutation, Throwable failure) {
|
||||
if (!pending.remove(key, token)) return;
|
||||
if (closed) return;
|
||||
if (failure != null) {
|
||||
report(key.character(), key.ability(), failure);
|
||||
input.player().sendMessage("The cast could not be saved; no effect was applied."); return;
|
||||
}
|
||||
boolean unchangedDeadline = mutation.status() == CharacterStorage.AbilityMutationStatus.UNCHANGED
|
||||
&& mutation.record().map(record -> readyAt.equals(record.cooldowns().get(key.ability()))).orElse(false);
|
||||
if ((mutation.status() != CharacterStorage.AbilityMutationStatus.APPLIED && !unchangedDeadline)
|
||||
|| mutation.record().isEmpty()) {
|
||||
input.player().sendMessage(switch (mutation.status()) {
|
||||
case COOLDOWN -> "That ability is still on cooldown.";
|
||||
case STALE -> "That character changed; retry the cast.";
|
||||
case NOT_LEARNED, UNAVAILABLE -> "That ability is no longer available to this character.";
|
||||
default -> "The cast was refused without applying an effect.";
|
||||
});
|
||||
return;
|
||||
}
|
||||
CharacterRecord saved = mutation.record().orElseThrow();
|
||||
boolean synchronizedProgress = unchangedDeadline
|
||||
? sessions.currentRecord(input.session()).filter(saved::equals).isPresent()
|
||||
: sessions.acknowledgeProgress(input.session(), expectedRevision, saved);
|
||||
if (!synchronizedProgress) {
|
||||
input.player().sendMessage("The cooldown was saved, but the session changed; no effect was applied."); return;
|
||||
}
|
||||
if (!input.stillCurrent() || generations.getOrDefault(key, 0L) != generation
|
||||
|| !eligible(definition.targetPolicy(), input, input.target())) {
|
||||
input.player().sendMessage("The cooldown was saved, but eligibility changed; no effect was applied."); return;
|
||||
}
|
||||
var runtime = new Runtime(key, input, definition, generation);
|
||||
active.computeIfAbsent(key, ignored -> new HashSet<>()).add(runtime);
|
||||
try {
|
||||
definition.effect().activate(runtime);
|
||||
if (definition.activation() == AbilityDefinition.Activation.IMMEDIATE) runtime.finish();
|
||||
} catch (RuntimeException effectFailure) {
|
||||
runtime.close(); report(key.character(), key.ability(), effectFailure);
|
||||
input.player().sendMessage("The cooldown was saved, but the ability effect failed safely.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Future refund/removal paths invalidate pending callbacks and close only this ability's owned work. */
|
||||
public void remove(UUID character, String ability) {
|
||||
var key = new Key(Objects.requireNonNull(character), Objects.requireNonNull(ability));
|
||||
generations.put(key, generations.getOrDefault(key, 0L) + 1);
|
||||
pending.remove(key);
|
||||
for (var runtime : List.copyOf(active.getOrDefault(key, Set.of()))) runtime.close();
|
||||
}
|
||||
|
||||
/** Bounded lifecycle sweep; plugin timers may call this without scanning future catalogues. */
|
||||
public void sweep() {
|
||||
if (closed) return;
|
||||
int checked = 0;
|
||||
outer: for (var runtimes : List.copyOf(active.values())) {
|
||||
for (var runtime : List.copyOf(runtimes)) {
|
||||
if (!runtime.stillCurrent()) runtime.close();
|
||||
if (++checked == 64) break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean eligible(AbilityDefinition.TargetPolicy policy, ControlInput input, Entity target) {
|
||||
return switch (policy) {
|
||||
case SELF -> target == null || target == input.player();
|
||||
case HOSTILE -> hostile(input, target);
|
||||
case PROJECTILE_HOSTILE -> target == null || hostile(input, target);
|
||||
case ALLY -> target instanceof Player player && fantasy(player);
|
||||
case PARTY -> target instanceof Player player
|
||||
&& (player == input.player() || fantasy(player) && sameParty(input.player(), player));
|
||||
case AREA -> target == null || target instanceof LivingEntity && (!(target instanceof Player player) || fantasy(player));
|
||||
case SELECTION -> target == null || target instanceof LivingEntity && (!(target instanceof Player player) || fantasy(player));
|
||||
case EXCEPTIONAL_NONCOMBAT -> target instanceof Player && target != input.player();
|
||||
};
|
||||
}
|
||||
|
||||
private boolean hostile(ControlInput input, Entity target) {
|
||||
return target instanceof LivingEntity && target != input.player()
|
||||
&& (!(target instanceof Player player) || fantasy(player) && !sameParty(input.player(), player));
|
||||
}
|
||||
|
||||
private boolean fantasy(Player player) { return sessions.active(player.getUniqueId()).filter(sessions::current).isPresent(); }
|
||||
private boolean sameParty(Player source, Player target) {
|
||||
return parties.partyOf(source.getUniqueId()).filter(party -> party.members().contains(target.getUniqueId())).isPresent();
|
||||
}
|
||||
|
||||
private <T> void schedule(UUID character, String ability, CompletableFuture<T> result, Runnable action) {
|
||||
try { nextTick.accept(action); }
|
||||
catch (RuntimeException rejected) {
|
||||
report(character, ability, rejected);
|
||||
if (result != null) result.completeExceptionally(rejected);
|
||||
}
|
||||
}
|
||||
|
||||
private void report(UUID character, String ability, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, ability, failure)); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics never authorize or replay an effect. */ }
|
||||
}
|
||||
|
||||
private final class Runtime implements AbilityExecution {
|
||||
private final Key key;
|
||||
private final ControlInput input;
|
||||
private final AbilityDefinition definition;
|
||||
private final long generation;
|
||||
private final Set<AutoCloseable> owned = new HashSet<>();
|
||||
private boolean finished;
|
||||
private int scheduled;
|
||||
|
||||
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation) {
|
||||
this.key = key; this.input = input; this.definition = definition; this.generation = generation;
|
||||
}
|
||||
@Override public AbilityDefinition definition() { return definition; }
|
||||
@Override public ControlInput input() { return input; }
|
||||
@Override public CharacterRecord character() {
|
||||
return sessions.currentRecord(input.session()).orElseThrow(() -> new IllegalStateException("Ability session ended"));
|
||||
}
|
||||
@Override public boolean stillCurrent() {
|
||||
return !closed && !finished && input.stillCurrent() && sessions.current(input.session())
|
||||
&& generations.getOrDefault(key, 0L) == generation
|
||||
&& sessions.currentRecord(input.session()).filter(record -> record.learned().contains(key.ability())).isPresent();
|
||||
}
|
||||
@Override public List<Entity> eligible(Collection<? extends Entity> candidates, int maximum) {
|
||||
Objects.requireNonNull(candidates);
|
||||
if (maximum < 1 || maximum > 64) throw new IllegalArgumentException("Recipient bound must be 1..64");
|
||||
var result = new ArrayList<Entity>(); int inspected = 0;
|
||||
for (var candidate : candidates) {
|
||||
if (++inspected > 256 || result.size() == maximum) break;
|
||||
if (stillCurrent() && AbilityEngine.this.eligible(definition.targetPolicy(), input, candidate)) result.add(candidate);
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
@Override public AbilityScheduler.Task later(Duration delay, Runnable action) {
|
||||
Objects.requireNonNull(delay); Objects.requireNonNull(action);
|
||||
if (delay.isNegative() || delay.compareTo(Duration.ofDays(365)) > 0 || ++scheduled > 64) {
|
||||
throw new IllegalArgumentException("Delayed effect is outside the bounded execution contract");
|
||||
}
|
||||
var task = scheduler.later(delay, () -> {
|
||||
if (!stillCurrent() || !AbilityEngine.this.eligible(definition.targetPolicy(), input, input.target())) {
|
||||
close(); return;
|
||||
}
|
||||
try { action.run(); }
|
||||
catch (RuntimeException failure) {
|
||||
close(); AbilityEngine.this.report(key.character(), key.ability(), failure);
|
||||
}
|
||||
});
|
||||
own(task); return task;
|
||||
}
|
||||
@Override public void own(AutoCloseable resource) {
|
||||
Objects.requireNonNull(resource);
|
||||
if (finished || owned.size() >= 64) {
|
||||
try { resource.close(); } catch (Exception ignored) { }
|
||||
if (!finished) throw new IllegalStateException("Ability owns too many resources");
|
||||
return;
|
||||
}
|
||||
owned.add(resource);
|
||||
}
|
||||
@Override public void report(AbilityReport.Type type, Entity target, java.math.BigDecimal amount) {
|
||||
if (!stillCurrent() || definition.targetPolicy() == AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT
|
||||
|| target == null || !AbilityEngine.this.eligible(definition.targetPolicy(), input, target)) return;
|
||||
reports.accept(new AbilityReport(type, key.character(), input.session().session(), target.getUniqueId(), amount, clock.instant()));
|
||||
}
|
||||
@Override public void finish() { close(); }
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
var resources = List.copyOf(owned); owned.clear();
|
||||
for (var resource : resources) try { resource.close(); } catch (Exception failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
|
||||
var runtimes = active.get(key);
|
||||
if (runtimes != null) { runtimes.remove(this); if (runtimes.isEmpty()) active.remove(key); }
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (sweepTask != null) sweepTask.cancel();
|
||||
pending.clear();
|
||||
for (var runtimes : List.copyOf(active.values())) for (var runtime : List.copyOf(runtimes)) runtime.close();
|
||||
active.clear(); generations.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import org.bukkit.entity.Entity;
|
||||
|
||||
/** Bounded owned runtime supplied by the shared engine after durable cast authorization. */
|
||||
public interface AbilityExecution extends AutoCloseable {
|
||||
AbilityDefinition definition();
|
||||
ControlInput input();
|
||||
games.dmg.fantasystick.persistence.CharacterRecord character();
|
||||
boolean stillCurrent();
|
||||
List<Entity> eligible(Collection<? extends Entity> candidates, int maximum);
|
||||
AbilityScheduler.Task later(Duration delay, Runnable action);
|
||||
void own(AutoCloseable resource);
|
||||
void report(AbilityReport.Type type, Entity target, BigDecimal amount);
|
||||
void finish();
|
||||
@Override void close();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** Process-local validated definitions; saved unknown IDs remain character data, not executable definitions. */
|
||||
public final class AbilityRegistry {
|
||||
private final ConcurrentHashMap<String, AbilityDefinition> definitions = new ConcurrentHashMap<>();
|
||||
|
||||
public void register(AbilityDefinition definition) {
|
||||
Objects.requireNonNull(definition);
|
||||
if (definitions.putIfAbsent(definition.id(), definition) != null) {
|
||||
throw new IllegalArgumentException("Duplicate ability ID: " + definition.id());
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<AbilityDefinition> find(String id) {
|
||||
return id == null ? Optional.empty() : Optional.ofNullable(definitions.get(id));
|
||||
}
|
||||
|
||||
public List<AbilityDefinition> definitions() {
|
||||
return definitions.values().stream().sorted(Comparator.comparing(AbilityDefinition::id)).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Evidence of an actual accepted effect; consumers decide rewards separately. */
|
||||
public record AbilityReport(Type type, UUID sourceCharacter, UUID sourceSession, UUID target,
|
||||
BigDecimal amount, Instant occurredAt) {
|
||||
public enum Type { DAMAGE, HEALING, CLEANSING, PREVENTION, BOOST_USED }
|
||||
public AbilityReport {
|
||||
Objects.requireNonNull(type); Objects.requireNonNull(sourceCharacter); Objects.requireNonNull(sourceSession);
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(amount); Objects.requireNonNull(occurredAt);
|
||||
if (amount.signum() <= 0 || !Double.isFinite(amount.doubleValue())) {
|
||||
throw new IllegalArgumentException("An effective report requires a positive finite amount");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/** Server-thread delayed-work boundary; returned tasks cancel only their owned callback. */
|
||||
@FunctionalInterface
|
||||
public interface AbilityScheduler {
|
||||
Task later(Duration delay, Runnable action);
|
||||
|
||||
interface Task extends AutoCloseable {
|
||||
@Override void close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Snowball;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.ProjectileHitEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/** Fireball's owned snowball projectile, direct custom damage and bounded cleanup adapter. */
|
||||
public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
|
||||
private static final double SPEED_PER_TICK = 1.0;
|
||||
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(lifetime);
|
||||
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 1024
|
||||
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Fireball settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(4), 32, Duration.ofSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
private final Settings settings;
|
||||
private final Map<UUID, State> projectiles = new HashMap<>();
|
||||
private State damaging;
|
||||
private boolean closed;
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final Snowball projectile;
|
||||
final AbilityExecution execution;
|
||||
final Location origin;
|
||||
final BigInteger intelligence;
|
||||
LivingEntity target;
|
||||
boolean finished;
|
||||
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence) {
|
||||
this.projectile = projectile; this.execution = execution; this.origin = origin; this.intelligence = intelligence;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
projectiles.remove(projectile.getUniqueId(), this);
|
||||
projectile.remove();
|
||||
}
|
||||
}
|
||||
|
||||
public Fireball(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
return new AbilityDefinition("fireball", "Fireball", Material.SNOWBALL,
|
||||
List.of("Launch a bounded fiery snowball.", "Deals " + settings.baseDamage() + " + INT modifier damage."),
|
||||
"Use the equipped snowball; a miss still spends the cooldown.",
|
||||
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, settings.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(settings.baseDamage()),
|
||||
"range", BigDecimal.valueOf(settings.range()).stripTrailingZeros(),
|
||||
"lifetime", BigDecimal.valueOf(settings.lifetime().toMillis(), 3).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
|
||||
}
|
||||
|
||||
public static BigInteger damage(BigInteger intelligence) {
|
||||
return damage(BigInteger.valueOf(6), intelligence);
|
||||
}
|
||||
|
||||
static BigInteger damage(BigInteger base, BigInteger intelligence) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
|
||||
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Fireball damage input");
|
||||
BigInteger delta = intelligence.subtract(BigInteger.TEN);
|
||||
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
BigInteger modifier = divided[0];
|
||||
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var player = execution.input().player();
|
||||
Location origin = player.getEyeLocation().clone();
|
||||
Vector direction = origin.getDirection();
|
||||
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|
||||
|| direction.lengthSquared() == 0) throw new IllegalStateException("Fireball direction is unavailable");
|
||||
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(SPEED_PER_TICK));
|
||||
projectile.setVisualFire(true);
|
||||
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT));
|
||||
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
|
||||
projectile.remove(); throw new IllegalStateException("Duplicate Fireball projectile identity");
|
||||
}
|
||||
execution.own(state);
|
||||
long rangeTicks = Math.max(1, (long) Math.ceil(settings.range() / SPEED_PER_TICK));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), execution::finish);
|
||||
execution.later(settings.lifetime(), execution::finish);
|
||||
player.getWorld().spawnParticle(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void hit(ProjectileHitEvent event) {
|
||||
var state = projectiles.remove(event.getEntity().getUniqueId());
|
||||
if (state == null) return;
|
||||
try {
|
||||
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|
||||
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|
||||
BigInteger amount = damage(settings.baseDamage(), state.intelligence);
|
||||
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
|
||||
var source = DamageSource.builder(DamageType.FIREBALL).withCausingEntity(state.execution.input().player())
|
||||
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
|
||||
state.target = target;
|
||||
damaging = state;
|
||||
try { target.damage(nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
} finally {
|
||||
state.execution.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean inRange(State state, Location current) {
|
||||
return current.getWorld() != null && state.origin.getWorld() != null
|
||||
&& current.getWorld().equals(state.origin.getWorld())
|
||||
&& current.distanceSquared(state.origin) <= settings.range() * settings.range();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || event.getEntity() != state.target || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage())) return;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|
||||
|| byEntity.getDamager() != state.projectile)) return;
|
||||
var causing = event.getDamageSource().getCausingEntity();
|
||||
if (causing != null && causing != state.execution.input().player()) return;
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
|
||||
projectiles.clear(); damaging = null;
|
||||
}
|
||||
}
|
||||
@@ -25,28 +25,47 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private final PartyBookControls partyControls;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> failures;
|
||||
private final games.dmg.fantasystick.ability.AbilityRegistry abilities;
|
||||
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
|
||||
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
|
||||
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 net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_ASSIGN = net.kyori.adventure.key.Key.key("fantasystick", "ability_assign");
|
||||
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 static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})(?::([0-6]))?\"");
|
||||
private record View(String nonce, ControlInput input, int previous, int next) { }
|
||||
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
|
||||
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
|
||||
private final Map<UUID, View> views = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, StatView> statViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, AbilityView> abilityViews = 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, rules, sessions, nextTick, failures, parties, server, names,
|
||||
new games.dmg.fantasystick.ability.AbilityRegistry(), null);
|
||||
}
|
||||
|
||||
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,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities,
|
||||
games.dmg.fantasystick.ability.AbilityEngine abilityEngine) {
|
||||
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.abilities = Objects.requireNonNull(abilities);
|
||||
this.abilityEngine = abilityEngine;
|
||||
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
|
||||
}
|
||||
|
||||
@@ -59,6 +78,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
partyControls.forget(owner);
|
||||
views.remove(owner);
|
||||
statViews.remove(owner);
|
||||
abilityViews.remove(owner);
|
||||
Object ticket = new Object();
|
||||
pending.put(owner, ticket);
|
||||
characters.find(input.session().character()).whenComplete((record, failure) -> {
|
||||
@@ -114,6 +134,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
.append(Component.newline());
|
||||
}
|
||||
pages.add(purchases);
|
||||
var definitions = abilities.definitions();
|
||||
if (!definitions.isEmpty() && end == all.size()) {
|
||||
var abilityView = new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
|
||||
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
|
||||
abilityViews.put(input.session().player(), abilityView);
|
||||
for (var definition : definitions) pages.addAll(abilityPages(record, definition, abilityView));
|
||||
}
|
||||
if (all.size() > VOLUME_SIZE) {
|
||||
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
|
||||
end < all.size() ? end : -1);
|
||||
@@ -129,6 +157,45 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
return item;
|
||||
}
|
||||
|
||||
private static java.util.List<Component> abilityPages(CharacterRecord record,
|
||||
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view) {
|
||||
String requirements = definition.requirements().isEmpty() ? "none" : definition.requirements().entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + " " + entry.getValue())
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
boolean learned = record.learned().contains(definition.id());
|
||||
boolean stats = definition.requirements().entrySet().stream()
|
||||
.allMatch(entry -> record.stats().get(entry.getKey()).compareTo(entry.getValue()) >= 0);
|
||||
String state = learned ? "Learned" : !stats ? "Unavailable: requirements"
|
||||
: record.unspentPoints().compareTo(definition.pointCost()) < 0 ? "Unavailable: points" : "Available";
|
||||
String parameters = definition.parameters().isEmpty() ? "" : "\nParameters: " + definition.parameters().entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + "=" + entry.getValue())
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
String text = definition.name() + "\nIcon: " + definition.icon().name()
|
||||
+ "\n\n" + String.join("\n", definition.lore()) + "\n\n" + definition.instructions()
|
||||
+ "\nRequires: " + requirements + "\nCost: " + definition.pointCost()
|
||||
+ "\nCooldown: " + definition.cooldown().toSeconds() + "s" + parameters + "\n\n" + state;
|
||||
var pages = new java.util.ArrayList<>(BookPagination.pages(text));
|
||||
if (!learned && stats && record.unspentPoints().compareTo(definition.pointCost()) >= 0) {
|
||||
pages.add(Component.text("Ability action\n\n").append(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id(), null)));
|
||||
} else if (learned) {
|
||||
Component action = Component.text("Assign " + definition.name() + "\n\nSlot: ");
|
||||
for (int slot = 0; slot < 7; slot++) {
|
||||
if (slot > 0) action = action.append(Component.space());
|
||||
action = action.append(abilityButton(Integer.toString(slot + 3), ABILITY_ASSIGN, view, definition.id(), slot));
|
||||
}
|
||||
pages.add(action);
|
||||
}
|
||||
return java.util.List.copyOf(pages);
|
||||
}
|
||||
|
||||
private static Component abilityButton(String label, net.kyori.adventure.key.Key action, AbilityView view,
|
||||
String ability, Integer slot) {
|
||||
String payload = "\"" + view.nonce() + ":" + ability + (slot == null ? "" : ":" + slot) + "\"";
|
||||
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(action,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder(payload)));
|
||||
}
|
||||
|
||||
private static Component pageButton(String label, View view, int direction) {
|
||||
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(PAGES,
|
||||
@@ -214,6 +281,51 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
load(input, 0);
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void abilityAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
boolean unlock = ABILITY_UNLOCK.equals(event.getIdentifier());
|
||||
boolean assign = ABILITY_ASSIGN.equals(event.getIdentifier());
|
||||
if (closed || abilityEngine == null || !unlock && !assign
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|
||||
|| event.getTag() == null) return;
|
||||
var player = connection.getPlayer();
|
||||
var view = abilityViews.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player) return;
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 120) return;
|
||||
var match = ABILITY_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|
||||
|| unlock != (match.group(3) == null)
|
||||
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
String ability = match.group(2);
|
||||
var operation = unlock ? abilityEngine.purchase(view.input(), ability, view.revision())
|
||||
: abilityEngine.assign(view.input(), ability, Integer.parseInt(match.group(3)), view.revision());
|
||||
operation.whenComplete((mutation, failure) -> {
|
||||
if (closed || !view.input().stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
report(view.input(), failure);
|
||||
player.sendMessage("Ability change could not be confirmed; reopen the character after session recovery.");
|
||||
return;
|
||||
}
|
||||
player.sendMessage(abilityMessage(unlock, ability, mutation.status()));
|
||||
load(view.input(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
private static String abilityMessage(boolean unlock, String ability, CharacterStorage.AbilityMutationStatus status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> unlock ? "Learned " + ability + "." : "Assigned " + ability + ".";
|
||||
case UNCHANGED -> ability + " is already assigned there.";
|
||||
case REQUIREMENTS -> "The purchased base-stat requirements are not met.";
|
||||
case NO_POINTS -> "This character does not have enough unspent points.";
|
||||
case ALREADY_LEARNED -> "This character already knows " + ability + ".";
|
||||
case NOT_LEARNED -> "Learn " + ability + " before assigning it.";
|
||||
case STALE -> "That character page is stale; reopen it before changing abilities.";
|
||||
case COOLDOWN -> "That ability is still on cooldown.";
|
||||
case UNAVAILABLE -> "That character or ability is unavailable.";
|
||||
};
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void partyAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { partyControls.click(event); }
|
||||
|
||||
@@ -223,8 +335,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
pending.remove(owner);
|
||||
views.remove(owner);
|
||||
statViews.remove(owner);
|
||||
abilityViews.remove(owner);
|
||||
partyControls.forget(owner);
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; pending.clear(); views.clear(); statViews.clear(); partyControls.close(); }
|
||||
@Override public void close() {
|
||||
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); partyControls.close();
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import org.bukkit.persistence.PersistentDataType;
|
||||
* The caller must enforce session custody and suppress vanilla item uses; this class registers no events.
|
||||
*/
|
||||
public final class SessionControls {
|
||||
private final games.dmg.fantasystick.ability.AbilityRegistry registry;
|
||||
private static final NamespacedKey VERSION = key("control_version");
|
||||
private static final NamespacedKey OWNER = key("control_owner_id");
|
||||
private static final NamespacedKey SESSION = key("control_session_id");
|
||||
@@ -51,6 +52,16 @@ public final class SessionControls {
|
||||
|
||||
public record Identity(UUID owner, UUID session, int slot) { }
|
||||
|
||||
public SessionControls() { this(new games.dmg.fantasystick.ability.AbilityRegistry()); }
|
||||
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry) {
|
||||
this.registry = Objects.requireNonNull(registry);
|
||||
}
|
||||
|
||||
private Icon icon(String ability) {
|
||||
var definition = registry.find(ability).orElse(null);
|
||||
return definition == null ? ABILITIES.get(ability) : new Icon(definition.icon(), definition.name());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the entire detached hotbar before a caller may start packing equipment.
|
||||
* Slot zero is a plain book control, not the pending character-book UI.
|
||||
@@ -62,10 +73,11 @@ public final class SessionControls {
|
||||
var items = new ItemStack[9];
|
||||
for (int slot = 0; slot < items.length; slot++) {
|
||||
String ability = slot < 2 ? "" : character.loadout().get(slot - 2);
|
||||
Icon abilityIcon = ability.isEmpty() ? null : icon(ability);
|
||||
Icon icon = slot == 0 ? new Icon(Material.BOOK, "Character book")
|
||||
: slot == 1 ? new Icon(Material.STICK, "Basic attack")
|
||||
: ability.isEmpty() ? new Icon(Material.GRAY_DYE, "No Ability")
|
||||
: ABILITIES.getOrDefault(ability, new Icon(Material.PAPER, "Unavailable ability: " + ability));
|
||||
: abilityIcon == null ? new Icon(Material.PAPER, "Unavailable ability: " + ability) : abilityIcon;
|
||||
var item = new ItemStack(icon.material());
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(icon.name()));
|
||||
@@ -76,7 +88,7 @@ public final class SessionControls {
|
||||
data.set(SESSION, PersistentDataType.STRING, session.toString());
|
||||
data.set(SLOT, PersistentDataType.INTEGER, slot);
|
||||
data.set(KIND, PersistentDataType.STRING, slot == 0 ? "book" : slot == 1 ? "basic_attack"
|
||||
: ability.isEmpty() ? "empty" : ABILITIES.containsKey(ability) ? "ability" : "unavailable");
|
||||
: ability.isEmpty() ? "empty" : abilityIcon != null ? "ability" : "unavailable");
|
||||
if (!ability.isEmpty()) data.set(ABILITY, PersistentDataType.STRING, ability);
|
||||
item.setItemMeta(meta);
|
||||
items[slot] = item;
|
||||
@@ -115,7 +127,7 @@ public final class SessionControls {
|
||||
? Optional.of(data.get(ABILITY, PersistentDataType.STRING)) : Optional.empty();
|
||||
}
|
||||
|
||||
private static boolean validKind(Material material, int slot, PersistentDataContainer data) {
|
||||
private boolean validKind(Material material, int slot, PersistentDataContainer data) {
|
||||
String kind = data.get(KIND, PersistentDataType.STRING);
|
||||
if (slot == 0) return kind.equals("book") && material == Material.BOOK && !data.has(ABILITY);
|
||||
if (slot == 1) return kind.equals("basic_attack") && material == Material.STICK && !data.has(ABILITY);
|
||||
@@ -123,7 +135,7 @@ public final class SessionControls {
|
||||
if (!data.has(ABILITY, PersistentDataType.STRING)) return false;
|
||||
String ability = data.get(ABILITY, PersistentDataType.STRING);
|
||||
if (ability.isBlank()) return false;
|
||||
Icon icon = ABILITIES.get(ability);
|
||||
Icon icon = icon(ability);
|
||||
return kind.equals("ability") ? icon != null && icon.material() == material
|
||||
: kind.equals("unavailable") && icon == null && material == Material.PAPER;
|
||||
}
|
||||
|
||||
@@ -294,6 +294,85 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
public enum AbilityMutationStatus {
|
||||
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
|
||||
}
|
||||
public record AbilityMutation(AbilityMutationStatus status, Optional<CharacterRecord> record) { }
|
||||
|
||||
/** Unlock one validated registered ID and spend its points in the same expected revision. */
|
||||
public CompletableFuture<AbilityMutation> unlockAbility(UUID characterId, BigInteger expectedRevision, String ability,
|
||||
java.util.Map<Stat, BigInteger> requirements, BigInteger cost) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
|
||||
Objects.requireNonNull(requirements); Objects.requireNonNull(cost);
|
||||
if (ability.isBlank() || cost.signum() <= 0 || requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)) {
|
||||
throw new IllegalArgumentException("Invalid ability unlock");
|
||||
}
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
|
||||
if (old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.ALREADY_LEARNED, Optional.of(old));
|
||||
if (requirements.entrySet().stream().anyMatch(entry -> old.stats().get(entry.getKey()).compareTo(entry.getValue()) < 0)) {
|
||||
return new AbilityMutation(AbilityMutationStatus.REQUIREMENTS, Optional.of(old));
|
||||
}
|
||||
if (old.unspentPoints().compareTo(cost) < 0) return new AbilityMutation(AbilityMutationStatus.NO_POINTS, Optional.of(old));
|
||||
var learned = new java.util.HashSet<>(old.learned()); learned.add(ability);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, old.loadout(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
/** Move one learned ability to one of the seven selectable slots without duplicating it. */
|
||||
public CompletableFuture<AbilityMutation> assignAbility(UUID characterId, BigInteger expectedRevision,
|
||||
String ability, int slot) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
|
||||
if (ability.isBlank() || slot < 0 || slot >= 7) throw new IllegalArgumentException("Invalid ability assignment");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
|
||||
if (!old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.NOT_LEARNED, Optional.of(old));
|
||||
var loadout = new java.util.ArrayList<>(old.loadout());
|
||||
for (int index = 0; index < loadout.size(); index++) if (loadout.get(index).equals(ability)) loadout.set(index, "");
|
||||
loadout.set(slot, ability);
|
||||
if (loadout.equals(old.loadout())) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), loadout, old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
/** Commit a real-time character/ability deadline before exposing the cast's native effect. */
|
||||
public CompletableFuture<AbilityMutation> commitAbilityCooldown(UUID characterId, BigInteger expectedRevision,
|
||||
String ability, Instant readyAt) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
|
||||
Objects.requireNonNull(readyAt);
|
||||
if (ability.isBlank()) throw new IllegalArgumentException("Invalid ability cooldown");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
|
||||
if (!old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.NOT_LEARNED, Optional.of(old));
|
||||
Instant previous = old.cooldowns().get(ability);
|
||||
if (previous != null && previous.isAfter(clock.instant())) return new AbilityMutation(AbilityMutationStatus.COOLDOWN, Optional.of(old));
|
||||
if (readyAt.equals(previous)) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
|
||||
var cooldowns = new java.util.HashMap<>(old.cooldowns()); cooldowns.put(ability, readyAt);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), cooldowns,
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
public enum StatPurchaseStatus { APPLIED, STALE, NO_POINTS, UNAVAILABLE }
|
||||
public record StatPurchase(StatPurchaseStatus status, Optional<CharacterRecord> record) { }
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<EquipmentSessions.Failure> diagnostics;
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private final SessionControls controls = new SessionControls();
|
||||
private SessionControls controls = new SessionControls();
|
||||
private static final class Intent { boolean started; }
|
||||
private final Map<UUID, Intent> swaps = new HashMap<>();
|
||||
private final Map<UUID, UUID> observed = new HashMap<>();
|
||||
@@ -88,6 +88,12 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
sessions.attachCursorCustody(this::cancelCustody);
|
||||
}
|
||||
|
||||
/** Install before registration so extensible controls keep the same structural containment catalogue. */
|
||||
public void attachAbilityRegistry(games.dmg.fantasystick.ability.AbilityRegistry registry) {
|
||||
if (closed || !draws.isEmpty()) throw new IllegalStateException("Input handling already started");
|
||||
controls = new SessionControls(Objects.requireNonNull(registry));
|
||||
}
|
||||
|
||||
/** The input recipient belongs to this plugin; it does not authorize vanilla item use. */
|
||||
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick,
|
||||
Consumer<EquipmentSessions.Failure> diagnostics, Consumer<ControlInput> inputs) {
|
||||
|
||||
@@ -46,7 +46,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private games.dmg.fantasystick.presentation.SessionPresentation presentation = games.dmg.fantasystick.presentation.SessionPresentation.NONE;
|
||||
private final SessionClaims claims = new SessionClaims();
|
||||
private final EquipmentStorage equipment = new EquipmentStorage();
|
||||
private final SessionControls controls = new SessionControls();
|
||||
private SessionControls controls = new SessionControls();
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private final Map<UUID, Attempt> pending = new HashMap<>();
|
||||
private final Map<UUID, Attempt> owned = new HashMap<>();
|
||||
@@ -142,6 +142,12 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
this.gameModes = Objects.requireNonNull(gameModes);
|
||||
}
|
||||
|
||||
/** Install before exposing entry so registered definitions drive delivered control metadata. */
|
||||
public synchronized void attachAbilityRegistry(games.dmg.fantasystick.ability.AbilityRegistry registry) {
|
||||
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
|
||||
controls = new SessionControls(Objects.requireNonNull(registry));
|
||||
}
|
||||
|
||||
/** Install before exposing entry; equipment-only adapter tests can omit the health overlay. */
|
||||
public synchronized void attachVitals(SessionVitals value) {
|
||||
if (vitals != null || !owned.isEmpty()) throw new IllegalStateException("Vitals already attached or sessions started");
|
||||
@@ -226,7 +232,15 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
|| !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;
|
||||
attempt.record = saved; // Persisted truth precedes any reentrant native presentation setter.
|
||||
if (!previous.loadout().equals(saved.loadout())) {
|
||||
var refreshed = controls.create(saved, attempt.owner, attempt.session);
|
||||
for (int slot = 2; slot < 9; slot++) {
|
||||
if (active.get(attempt.owner) != attempt || !current(session)) return false;
|
||||
attempt.player.getInventory().setItem(slot, refreshed[slot]);
|
||||
}
|
||||
attempt.player.updateInventory(); // No planned native writes after this reentrant boundary.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,3 +12,9 @@ passive-xp: 1
|
||||
ordinary-xp: 10
|
||||
boss-xp: 100
|
||||
restricted-kill-spacing-blocks: 3
|
||||
|
||||
# Fireball defaults from the reviewed starter catalogue.
|
||||
fireball-base-damage: 6
|
||||
fireball-cooldown-seconds: 4
|
||||
fireball-range-blocks: 32
|
||||
fireball-lifetime-seconds: 5
|
||||
@@ -0,0 +1,225 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.ability.AbilityDefinition;
|
||||
import games.dmg.fantasystick.ability.AbilityRegistry;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class AbilityEngineNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void executionContractsRevalidateTargetsBoundAreasReportOnlyEffectsAndCleanOwnedWork() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var registry = new AbilityRegistry();
|
||||
var reports = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityReport>();
|
||||
var delayed = new java.util.ArrayList<Runnable>();
|
||||
var closedResources = new AtomicInteger();
|
||||
var delayedEffects = new AtomicInteger();
|
||||
var selectionEffects = new AtomicInteger();
|
||||
var allyEffects = new AtomicInteger();
|
||||
var exceptionalEffects = new AtomicInteger();
|
||||
var areaRecipients = new java.util.ArrayList<org.bukkit.entity.Entity>();
|
||||
var areaCandidates = new java.util.concurrent.atomic.AtomicReference<List<? extends org.bukkit.entity.Entity>>(List.of());
|
||||
registry.register(definition("report_test", AbilityDefinition.Activation.IMMEDIATE,
|
||||
AbilityDefinition.TargetPolicy.HOSTILE, execution -> {
|
||||
for (var type : games.dmg.fantasystick.ability.AbilityReport.Type.values()) {
|
||||
execution.report(type, execution.input().target(), BigDecimal.valueOf(2));
|
||||
}
|
||||
}));
|
||||
registry.register(definition("area_test", AbilityDefinition.Activation.IMMEDIATE,
|
||||
AbilityDefinition.TargetPolicy.AREA, execution -> areaRecipients.addAll(execution.eligible(areaCandidates.get(), 2))));
|
||||
registry.register(definition("delayed_test", AbilityDefinition.Activation.DELAYED,
|
||||
AbilityDefinition.TargetPolicy.PARTY, execution -> execution.later(Duration.ofSeconds(1), () -> {
|
||||
delayedEffects.incrementAndGet(); execution.finish();
|
||||
})));
|
||||
registry.register(definition("selection_test", AbilityDefinition.Activation.SELECTION,
|
||||
AbilityDefinition.TargetPolicy.SELECTION, execution -> execution.later(Duration.ofSeconds(1), () -> {
|
||||
selectionEffects.incrementAndGet(); execution.finish();
|
||||
})));
|
||||
registry.register(definition("projectile_test", AbilityDefinition.Activation.PROJECTILE,
|
||||
AbilityDefinition.TargetPolicy.HOSTILE, execution -> execution.own(closedResources::incrementAndGet)));
|
||||
registry.register(definition("ally_test", AbilityDefinition.Activation.IMMEDIATE,
|
||||
AbilityDefinition.TargetPolicy.ALLY, execution -> allyEffects.incrementAndGet()));
|
||||
registry.register(definition("exception_test", AbilityDefinition.Activation.IMMEDIATE,
|
||||
AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT, execution -> exceptionalEffects.incrementAndGet()));
|
||||
|
||||
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);
|
||||
prepare(data, a, List.of("report_test", "area_test", "delayed_test", "selection_test", "projectile_test", "ally_test", "exception_test"));
|
||||
f.activate(a); f.activate(b);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
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()));
|
||||
games.dmg.fantasystick.ability.AbilityScheduler scheduler = (delay, action) -> {
|
||||
delayed.add(action); return closedResources::incrementAndGet;
|
||||
};
|
||||
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry,
|
||||
(CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"), sessions, parties,
|
||||
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC),
|
||||
f.tasks::add, scheduler, reports::add, failure -> fail(failure.toString()));
|
||||
var active = sessions.active(a.core.id).orElseThrow();
|
||||
var hostile = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
|
||||
org.mockito.Mockito.when(hostile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
cast(engine, f, a, active, 2, "report_test", hostile);
|
||||
assertEquals(java.util.Set.copyOf(java.util.Arrays.asList(games.dmg.fantasystick.ability.AbilityReport.Type.values())),
|
||||
reports.stream().map(games.dmg.fantasystick.ability.AbilityReport::type).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(5, reports.size());
|
||||
assertEquals(a.core.record.id(), reports.getFirst().sourceCharacter());
|
||||
|
||||
var ordinary = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
|
||||
org.mockito.Mockito.when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
areaCandidates.set(List.of(hostile, ordinary, b.player));
|
||||
active = sessions.active(a.core.id).orElseThrow();
|
||||
cast(engine, f, a, active, 3, "area_test", null);
|
||||
assertEquals(List.of(hostile, b.player), areaRecipients);
|
||||
assertEquals(5, reports.size(), "Casting and no-op/recipient discovery do not manufacture reports");
|
||||
|
||||
active = sessions.active(a.core.id).orElseThrow();
|
||||
cast(engine, f, a, active, 7, "ally_test", b.player);
|
||||
assertEquals(1, allyEffects.get());
|
||||
active = sessions.active(a.core.id).orElseThrow();
|
||||
cast(engine, f, a, active, 8, "exception_test", ordinary);
|
||||
assertEquals(1, exceptionalEffects.get(), "Explicit non-combat policy may select an ordinary player");
|
||||
|
||||
active = sessions.active(a.core.id).orElseThrow();
|
||||
cast(engine, f, a, active, 4, "delayed_test", b.player);
|
||||
assertEquals(1, delayed.size());
|
||||
assertTrue(parties.leave(a.core.id, party.id()));
|
||||
delayed.removeFirst().run();
|
||||
assertEquals(0, delayedEffects.get(), "Party eligibility is rechecked at delayed application");
|
||||
|
||||
active = sessions.active(a.core.id).orElseThrow();
|
||||
cast(engine, f, a, active, 5, "selection_test", b.player);
|
||||
assertEquals(1, delayed.size());
|
||||
engine.remove(a.core.record.id(), "selection_test");
|
||||
delayed.removeFirst().run();
|
||||
assertEquals(0, selectionEffects.get(), "Removal invalidates pending selection callbacks without clearing cooldowns");
|
||||
var stored = ((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
|
||||
.find(a.core.record.id()).get().orElseThrow();
|
||||
assertTrue(stored.cooldowns().containsKey("selection_test"));
|
||||
|
||||
active = sessions.active(a.core.id).orElseThrow();
|
||||
cast(engine, f, a, active, 6, "projectile_test", hostile);
|
||||
int beforeExit = closedResources.get();
|
||||
PartyCombatNativeTest.exit(f, a);
|
||||
engine.sweep();
|
||||
assertEquals(beforeExit + 1, closedResources.get(), "Session end cleans the projectile definition's owned resource");
|
||||
engine.close();
|
||||
assertEquals(beforeExit + 1, closedResources.get(), "Repeated engine cleanup cannot close owned state twice");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredTestDefinitionUsesRealBookPersistenceControlAndCastRoutes() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
var effects = new AtomicInteger();
|
||||
registry.register(new AbilityDefinition("test_focus", "Test Focus", Material.AMETHYST_SHARD,
|
||||
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
|
||||
Map.of("range", BigDecimal.valueOf(12)), AbilityDefinition.TargetPolicy.SELF,
|
||||
execution -> { assertTrue(execution.stillCurrent()); effects.incrementAndGet(); }));
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0);
|
||||
|
||||
PartyBookNativeTest.open(f, a);
|
||||
String available = PartyBookNativeTest.text(a);
|
||||
String compactAvailable = available.replaceAll("\\s+", "");
|
||||
assertTrue(compactAvailable.contains("TestFocus"), available);
|
||||
assertTrue(compactAvailable.contains("AMETHYST_SHARD"), available);
|
||||
assertTrue(compactAvailable.contains("Cost:1"), available);
|
||||
assertTrue(compactAvailable.contains("Cooldown:4s"), available);
|
||||
var learn = PartyBookNativeTest.button(a, "[Learn]");
|
||||
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var learned = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(java.util.Set.of("test_focus"), learned.learned());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints(), "Repeated stale book action cannot spend twice");
|
||||
|
||||
var assign = PartyBookNativeTest.button(a, "[3]");
|
||||
PartyBookNativeTest.click(f, a, assign); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals("test_focus", assigned.loadout().getFirst());
|
||||
var control = a.core.inventory.getItem(2);
|
||||
assertEquals(Material.AMETHYST_SHARD, control.getType());
|
||||
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry).ability(control).orElseThrow());
|
||||
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR, control,
|
||||
null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand());
|
||||
assertEquals(1, effects.get());
|
||||
var cooled = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertTrue(cooled.cooldowns().get("test_focus").isAfter(java.time.Instant.now()));
|
||||
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
|
||||
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(1, effects.get(), "The persisted character deadline refuses an immediate repeated cast");
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry)
|
||||
.ability(a.core.inventory.getItem(2)).orElseThrow());
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
|
||||
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(1, effects.get(), "Exit/resume cannot reset the persisted character cooldown");
|
||||
assertEquals(cooled.cooldowns(), storage.find(a.core.record.id()).get().orElseThrow().cooldowns());
|
||||
}
|
||||
}
|
||||
|
||||
private static AbilityDefinition definition(String id, AbilityDefinition.Activation activation,
|
||||
AbilityDefinition.TargetPolicy targetPolicy, games.dmg.fantasystick.ability.AbilityEffect effect) {
|
||||
return new AbilityDefinition(id, id, Material.AMETHYST_SHARD, List.of("Test definition"), "Use test input",
|
||||
Map.of(), BigInteger.ONE, activation, ControlInput.Gesture.USE, Duration.ZERO,
|
||||
Map.of(), targetPolicy, effect);
|
||||
}
|
||||
|
||||
private static void prepare(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> loadout) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var learned = loadout.stream().filter(id -> !id.isEmpty()).collect(java.util.stream.Collectors.toSet());
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
|
||||
old.stats(), old.level(), old.experience(), old.unspentPoints(), old.health(), learned, loadout,
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null);
|
||||
repository.replace(saved, old.revision());
|
||||
}
|
||||
|
||||
private static void cast(games.dmg.fantasystick.ability.AbilityEngine engine,
|
||||
FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active, int slot, String id,
|
||||
org.bukkit.entity.Entity target) throws Exception {
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
engine.accept(new ControlInput(actor.player, active, slot, id, ControlInput.Kind.ABILITY,
|
||||
ControlInput.Gesture.USE, target, 0, 0, () -> sessions.current(active)));
|
||||
PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import games.dmg.fantasystick.ability.AbilityDefinition;
|
||||
import games.dmg.fantasystick.ability.AbilityRegistry;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.bukkit.Material;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AbilityRegistryNativeTest {
|
||||
@org.junit.jupiter.api.BeforeAll static void bootstrap() throws Exception {
|
||||
games.dmg.fantasystick.NativeRuntime.bootstrap();
|
||||
}
|
||||
@Test void registersCompleteStableDefinitionsAndRejectsDuplicateIds() {
|
||||
var registry = new AbilityRegistry();
|
||||
var definition = definition("test_bolt");
|
||||
registry.register(definition);
|
||||
assertSame(definition, registry.find("test_bolt").orElseThrow());
|
||||
assertEquals(List.of(definition), registry.definitions());
|
||||
assertThrows(IllegalArgumentException.class, () -> registry.register(definition("test_bolt")));
|
||||
assertTrue(registry.find("unknown_saved_id").isEmpty());
|
||||
assertTrue(registry.find("basic_attack").isEmpty(), "The built-in stick is not a learned/refundable definition");
|
||||
}
|
||||
|
||||
@Test void rejectsMalformedMetadataRequirementsAndNumericParametersBeforeRegistration() {
|
||||
var valid = definition("valid");
|
||||
assertAll(
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "UPPER", valid.requirements(), valid.parameters(), valid.cooldown())),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "", valid.requirements(), valid.parameters(), valid.cooldown())),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("x", " ", Material.STONE,
|
||||
List.of("Lore"), "Use", Map.of(Stat.INT, BigInteger.TEN), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ZERO,
|
||||
Map.of(), AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {})),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "low", Map.of(Stat.INT, BigInteger.valueOf(9)), Map.of(), Duration.ZERO)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "negative", valid.requirements(), Map.of("range", BigDecimal.valueOf(-1)), Duration.ZERO)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "infinite", valid.requirements(), Map.of("range", new BigDecimal("1E1000000")), Duration.ZERO)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "cooldown", valid.requirements(), Map.of(), Duration.ofSeconds(-1))),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "long_cooldown", valid.requirements(), Map.of(), Duration.ofDays(366))),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("air", "Air", Material.AIR,
|
||||
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, AbilityDefinition.Activation.IMMEDIATE,
|
||||
ControlInput.Gesture.USE, Duration.ZERO, Map.of(), AbilityDefinition.TargetPolicy.SELF, ignored -> {}))
|
||||
);
|
||||
}
|
||||
|
||||
@Test void effectReportsRequireActualPositiveFiniteEvidence() {
|
||||
var character = java.util.UUID.randomUUID(); var session = java.util.UUID.randomUUID(); var target = java.util.UUID.randomUUID();
|
||||
var now = java.time.Instant.parse("2026-09-19T20:00:00Z");
|
||||
for (var type : games.dmg.fantasystick.ability.AbilityReport.Type.values()) {
|
||||
assertDoesNotThrow(() -> new games.dmg.fantasystick.ability.AbilityReport(type, character, session, target,
|
||||
BigDecimal.ONE, now));
|
||||
assertThrows(IllegalArgumentException.class, () -> new games.dmg.fantasystick.ability.AbilityReport(type,
|
||||
character, session, target, BigDecimal.ZERO, now));
|
||||
assertThrows(IllegalArgumentException.class, () -> new games.dmg.fantasystick.ability.AbilityReport(type,
|
||||
character, session, target, new BigDecimal("1E1000000"), now));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void definitionsSupportEveryBoundedExecutionStyleAndTargetBoundary() {
|
||||
for (var activation : AbilityDefinition.Activation.values()) {
|
||||
for (var policy : AbilityDefinition.TargetPolicy.values()) {
|
||||
var base = definition("x");
|
||||
assertDoesNotThrow(() -> new AbilityDefinition("a_" + activation.name().toLowerCase() + "_" + policy.name().toLowerCase(),
|
||||
base.name(), base.icon(), base.lore(), base.instructions(), base.requirements(), base.pointCost(), activation,
|
||||
base.gesture(), base.cooldown(), base.parameters(), policy, base.effect()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static AbilityDefinition definition(String id) {
|
||||
return new AbilityDefinition(id, "Test Bolt", Material.AMETHYST_SHARD, List.of("A focused test effect"),
|
||||
"Use the equipped control", Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
|
||||
Map.of("range", BigDecimal.valueOf(12), "duration", BigDecimal.valueOf(5)),
|
||||
AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {});
|
||||
}
|
||||
|
||||
private static AbilityDefinition copy(AbilityDefinition value, String id, Map<Stat, BigInteger> requirements,
|
||||
Map<String, BigDecimal> parameters, Duration cooldown) {
|
||||
return new AbilityDefinition(id, value.name(), value.icon(), value.lore(), value.instructions(), requirements,
|
||||
value.pointCost(), value.activation(), value.gesture(), cooldown, parameters, value.targetPolicy(), value.effect());
|
||||
}
|
||||
}
|
||||
@@ -60,11 +60,15 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
|
||||
assertNotNull(fixture.packetListener);
|
||||
assertEquals(4, fixture.timers.size(), "Presentation, provenance, combat-reward retry and session particles");
|
||||
assertEquals(5, fixture.timers.size(), "Presentation, ability cleanup, provenance, 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("Fireball") >= 0);
|
||||
assertTrue(fixture.order.indexOf("Fireball") < recipe);
|
||||
assertNotNull(Fixture.field(fixture.plugin, "abilityEngine"));
|
||||
assertNotNull(Fixture.field(fixture.plugin, "abilityRegistry"));
|
||||
assertTrue(fixture.order.indexOf("BasicAttack") >= 0);
|
||||
assertTrue(fixture.order.indexOf("BasicAttack") < recipe);
|
||||
assertTrue(fixture.order.indexOf("NutritionPolicy") >= 0);
|
||||
@@ -134,7 +138,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
|
||||
@Test
|
||||
void recipeCollisionAndPartialListenerRegistrationFailureRollbackOnlyThisEnableAttempt() throws Exception {
|
||||
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "presentation-timer")) {
|
||||
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "Fireball", "presentation-timer")) {
|
||||
boolean collision = stage.equals("collision");
|
||||
try (var fixture = new Fixture(data)) {
|
||||
var existing = new org.bukkit.inventory.ShapelessRecipe(CharacterSheets.RECIPE,
|
||||
@@ -870,6 +874,22 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidFireballConfigurationFailsBeforeNativeRegistrationWithoutRewritingConfig() throws Exception {
|
||||
int scenario = 0;
|
||||
for (String row : List.of("fireball-base-damage: -1", "fireball-cooldown-seconds: -1",
|
||||
"fireball-range-blocks: 0", "fireball-range-blocks: 1e1000000",
|
||||
"fireball-lifetime-seconds: 0", "fireball-lifetime-seconds: 61")) {
|
||||
var directory = data.resolve("invalid-fireball-" + scenario++); java.nio.file.Files.createDirectories(directory);
|
||||
String config = row + "\n"; java.nio.file.Files.writeString(directory.resolve("config.yml"), config);
|
||||
try (var f = new Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true), row);
|
||||
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty()); assertTrue(f.tasks.isEmpty());
|
||||
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
|
||||
assertEquals(config, java.nio.file.Files.readString(directory.resolve("config.yml")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidCombatRewardConfigurationFailsBeforeNativeRegistrationWithoutRewritingConfig() throws Exception {
|
||||
int scenario = 0;
|
||||
for (String row : List.of("passive-xp: -1", "ordinary-xp: 1.5", "boss-xp: nope",
|
||||
@@ -1081,6 +1101,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
};
|
||||
final List<BukkitTask> timers = new ArrayList<>();
|
||||
final List<Runnable> pulses = new ArrayList<>();
|
||||
final List<Runnable> delayed = new ArrayList<>();
|
||||
com.comphenix.protocol.events.PacketListener packetListener;
|
||||
final List<java.util.concurrent.ExecutorService> workers = new ArrayList<>();
|
||||
final Thread main = Thread.currentThread();
|
||||
@@ -1149,6 +1170,12 @@ class FantasyStickLifecycleNativeTest {
|
||||
tasks.add((Runnable) args[1]);
|
||||
yield port(BukkitTask.class, (method, values) -> { throw new AssertionError("Unexpected task call " + method); });
|
||||
}
|
||||
case "runTaskLater" -> {
|
||||
schedulerCalls.incrementAndGet();
|
||||
assertTrue(((org.bukkit.plugin.Plugin) args[0]).isEnabled());
|
||||
delayed.add((Runnable) args[1]);
|
||||
var task = mock(BukkitTask.class); timers.add(task); yield task;
|
||||
}
|
||||
default -> throw new AssertionError("Unexpected scheduler call " + name);
|
||||
});
|
||||
server = port(Server.class, (name, args) -> switch (name) {
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
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 FireballNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedUseAndDirectHitDealOneAttributedScaledDamageWithCooldown() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withInt(data, a, 12);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
assertTrue(PartyBookNativeTest.text(a).contains("Fireball"));
|
||||
assertTrue(PartyBookNativeTest.text(a).contains("INT 12"));
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[3]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertTrue(assigned.learned().contains("fireball")); assertEquals("fireball", assigned.loadout().getFirst());
|
||||
assertEquals(BigInteger.valueOf(39), assigned.unspentPoints());
|
||||
assertEquals(org.bukkit.Material.SNOWBALL, a.core.inventory.getItem(2).getType());
|
||||
|
||||
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0);
|
||||
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
|
||||
org.mockito.Mockito.doReturn(eye).when(a.player).getEyeLocation();
|
||||
var projectile = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
|
||||
var launches = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var launchedVelocity = new java.util.concurrent.atomic.AtomicReference<org.bukkit.util.Vector>();
|
||||
org.mockito.Mockito.doAnswer(call -> {
|
||||
launches.incrementAndGet(); launchedVelocity.set(call.getArgument(1));
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
|
||||
return projectile;
|
||||
}).when(a.player).launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
|
||||
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand()); assertEquals(1, launches.get());
|
||||
org.mockito.Mockito.verify(projectile).setVisualFire(true);
|
||||
assertEquals(new org.bukkit.util.Vector(1, 0, 0), launchedVelocity.get());
|
||||
|
||||
var target = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
|
||||
org.mockito.Mockito.when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
org.mockito.Mockito.when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
org.mockito.Mockito.when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 10, 65.6, 0));
|
||||
var damages = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
org.mockito.Mockito.doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0));
|
||||
damages.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, target));
|
||||
assertEquals(1, damages.size()); assertEquals(7.0, damages.getFirst().getDamage());
|
||||
assertFalse(damages.getFirst().isCancelled());
|
||||
assertSame(projectile, damages.getFirst().getDamager());
|
||||
assertEquals(org.bukkit.damage.DamageType.FIREBALL, damages.getFirst().getDamageSource().getDamageType());
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
|
||||
new java.util.ArrayList<>(), 0));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(BigInteger.TEN, storage.find(a.core.record.id()).get().orElseThrow().experience(),
|
||||
"Native projectile attribution reaches the originating character's combat XP");
|
||||
org.mockito.Mockito.verify(projectile, org.mockito.Mockito.atLeastOnce()).remove();
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, target));
|
||||
assertEquals(1, damages.size(), "A projectile can deal custom damage only once");
|
||||
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(1, launches.get(), "The persisted four-second deadline refuses immediate reuse");
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball");
|
||||
assertTrue(deadline.isAfter(java.time.Instant.now()));
|
||||
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
assertEquals("fireball", new games.dmg.fantasystick.items.SessionControls(
|
||||
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
|
||||
.ability(a.core.inventory.getItem(2)).orElseThrow());
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(1, launches.get(), "Exit/resume and control reconstruction preserve the same Fireball deadline");
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partyAndMixedModeProjectileHitsPreserveProtectionCancellation() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-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);
|
||||
withFireball(data, a, 12);
|
||||
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()));
|
||||
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var first = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
|
||||
var second = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
|
||||
prepareLaunches(f, a, first, second);
|
||||
cast(f, a); var partyHit = new org.bukkit.event.entity.ProjectileHitEvent(first, b.player); f.dispatcher.accept(partyHit);
|
||||
assertTrue(partyHit.isCancelled());
|
||||
org.mockito.Mockito.verify(b.player, org.mockito.Mockito.never()).damage(org.mockito.ArgumentMatchers.anyDouble(),
|
||||
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
|
||||
|
||||
var ordinary = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
|
||||
org.mockito.Mockito.when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
cast(f, a); var mixedHit = new org.bukkit.event.entity.ProjectileHitEvent(second, ordinary); f.dispatcher.accept(mixedHit);
|
||||
assertTrue(mixedHit.isCancelled());
|
||||
org.mockito.Mockito.verify(ordinary, org.mockito.Mockito.never()).damage(org.mockito.ArgumentMatchers.anyDouble(),
|
||||
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
|
||||
assertTrue(org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream()
|
||||
.noneMatch(invocation -> invocation.getMethod().getName().equals("createExplosion")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rangeLifetimeAndSessionEndEachRemoveOwnedProjectileWithoutLateDamage() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFireball(data, a, 12);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var outside = projectile(a, new org.bukkit.Location(a.world, 32.01, 65.6, 0));
|
||||
var boundary = projectile(a, new org.bukkit.Location(a.world, 32, 65.6, 0));
|
||||
var expiring = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
|
||||
var exiting = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
|
||||
prepareLaunches(f, a, outside, boundary, expiring, exiting);
|
||||
var target = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
|
||||
org.mockito.Mockito.when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(outside, target));
|
||||
org.mockito.Mockito.verify(target, org.mockito.Mockito.never()).damage(org.mockito.ArgumentMatchers.anyDouble(),
|
||||
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
|
||||
org.mockito.Mockito.verify(outside, org.mockito.Mockito.atLeastOnce()).remove();
|
||||
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(boundary, target));
|
||||
org.mockito.Mockito.verify(target).damage(org.mockito.ArgumentMatchers.eq(7.0),
|
||||
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
|
||||
|
||||
cast(f, a); Runnable lifetime = f.delayed.getLast(); lifetime.run();
|
||||
org.mockito.Mockito.verify(expiring, org.mockito.Mockito.atLeastOnce()).remove();
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(expiring, target));
|
||||
org.mockito.Mockito.verify(target, org.mockito.Mockito.times(1)).damage(org.mockito.ArgumentMatchers.eq(7.0),
|
||||
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
|
||||
|
||||
cast(f, a);
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine")).sweep();
|
||||
org.mockito.Mockito.verify(exiting, org.mockito.Mockito.atLeastOnce()).remove();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void validatedConfiguredBalanceReachesTheRegisteredDefinition() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-base-damage: 9\n"
|
||||
+ "fireball-cooldown-seconds: 2\nfireball-range-blocks: 40\nfireball-lifetime-seconds: 7\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
var definition = registry.find("fireball").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
|
||||
assertEquals(new java.math.BigDecimal("9"), definition.parameters().get("base-damage"));
|
||||
assertEquals(0, new java.math.BigDecimal("40").compareTo(definition.parameters().get("range")));
|
||||
assertEquals(new java.math.BigDecimal("7"), definition.parameters().get("lifetime"));
|
||||
assertTrue(String.join(" ", definition.lore()).contains("Deals 9"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceLaunchesNoProjectileOrFreeEffect() {
|
||||
var storage = org.mockito.Mockito.mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
|
||||
var sessions = org.mockito.Mockito.mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
|
||||
var registry = new games.dmg.fantasystick.ability.AbilityRegistry();
|
||||
var fireball = new games.dmg.fantasystick.ability.Fireball(games.dmg.fantasystick.ability.Fireball.Settings.defaults());
|
||||
registry.register(fireball.definition());
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new java.util.EnumMap<>(base.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12));
|
||||
var record = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), base.name(), base.revision(), stats,
|
||||
base.level(), base.experience(), base.unspentPoints(), base.health(), java.util.Set.of("fireball"),
|
||||
java.util.List.of("fireball", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
|
||||
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
|
||||
java.util.UUID.randomUUID(), record.id());
|
||||
var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
|
||||
org.mockito.Mockito.when(sessions.current(active)).thenReturn(true);
|
||||
org.mockito.Mockito.when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
|
||||
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutation>();
|
||||
failed.completeExceptionally(new java.io.IOException("simulated cooldown failure"));
|
||||
org.mockito.Mockito.when(storage.commitAbilityCooldown(org.mockito.ArgumentMatchers.eq(record.id()),
|
||||
org.mockito.ArgumentMatchers.eq(record.revision()), org.mockito.ArgumentMatchers.eq("fireball"),
|
||||
org.mockito.ArgumentMatchers.any())).thenReturn(failed);
|
||||
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
|
||||
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
|
||||
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
|
||||
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "fireball",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
|
||||
org.mockito.Mockito.verify(player, org.mockito.Mockito.never()).launchProjectile(
|
||||
org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class), org.mockito.ArgumentMatchers.any());
|
||||
assertEquals(1, failures.size());
|
||||
engine.close(); fireball.close(); parties.close();
|
||||
}
|
||||
|
||||
@Test void formulaAndPackagedDefinitionMatchTheReviewedFireballContract() throws Exception {
|
||||
assertEquals(BigInteger.valueOf(6), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.TEN));
|
||||
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.valueOf(13)));
|
||||
assertEquals(BigInteger.valueOf(8), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.valueOf(14)));
|
||||
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
|
||||
games.dmg.fantasystick.ability.Fireball.damage(BigInteger.TEN.pow(500)));
|
||||
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
var fireball = registry.find("fireball").orElseThrow();
|
||||
assertEquals("Fireball", fireball.name());
|
||||
assertEquals(org.bukkit.Material.SNOWBALL, fireball.icon());
|
||||
assertEquals(java.util.Map.of(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12)),
|
||||
fireball.requirements());
|
||||
assertEquals(BigInteger.ONE, fireball.pointCost());
|
||||
assertEquals(java.time.Duration.ofSeconds(4), fireball.cooldown());
|
||||
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.USE, fireball.gesture());
|
||||
assertEquals(games.dmg.fantasystick.ability.AbilityDefinition.Activation.PROJECTILE, fireball.activation());
|
||||
assertEquals(new java.math.BigDecimal("32"), fireball.parameters().get("range"));
|
||||
assertEquals(new java.math.BigDecimal("5"), fireball.parameters().get("lifetime"));
|
||||
var unavailable = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(unavailable.core.id, unavailable.player); f.activate(unavailable);
|
||||
unavailable.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, unavailable);
|
||||
String unavailableText = PartyBookNativeTest.text(unavailable);
|
||||
assertTrue(unavailableText.replaceAll("\\s+", "").contains("Unavailable:requirements"), unavailableText);
|
||||
assertThrows(AssertionError.class, () -> PartyBookNativeTest.button(unavailable, "[Learn]"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void withFireball(Path data, FantasyStickLifecycleNativeTest.Actor actor, int intelligence) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(intelligence));
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), java.util.Set.of("fireball"),
|
||||
java.util.List.of("fireball", "", "", "", "", "", ""), old.cooldowns(), old.restrictedKills(), false, null, null);
|
||||
repository.replace(saved, old.revision());
|
||||
}
|
||||
|
||||
private static void prepareLaunches(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor,
|
||||
org.bukkit.entity.Snowball... projectiles) {
|
||||
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
|
||||
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
|
||||
org.mockito.Mockito.doReturn(eye).when(actor.player).getEyeLocation();
|
||||
var queue = new java.util.ArrayDeque<>(java.util.List.of(projectiles));
|
||||
org.mockito.Mockito.doAnswer(call -> {
|
||||
var projectile = queue.removeFirst();
|
||||
fixture.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
|
||||
return projectile;
|
||||
}).when(actor.player).launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
|
||||
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
|
||||
}
|
||||
|
||||
private static void cast(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(actor.player,
|
||||
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, actor.core.inventory.getItem(2), null,
|
||||
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static void withInt(Path data, FantasyStickLifecycleNativeTest.Actor actor, int intelligence) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(intelligence));
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null);
|
||||
repository.replace(saved, old.revision());
|
||||
}
|
||||
|
||||
private static org.bukkit.entity.Snowball projectile(FantasyStickLifecycleNativeTest.Actor actor,
|
||||
org.bukkit.Location location) {
|
||||
var projectile = org.mockito.Mockito.mock(org.bukkit.entity.Snowball.class);
|
||||
org.mockito.Mockito.when(projectile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
org.mockito.Mockito.when(projectile.getShooter()).thenReturn(actor.player);
|
||||
org.mockito.Mockito.when(projectile.getLocation()).thenAnswer(ignored -> location.clone());
|
||||
org.mockito.Mockito.when(projectile.getWorld()).thenReturn(location.getWorld());
|
||||
org.mockito.Mockito.when(projectile.getPersistentDataContainer()).thenReturn(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
return projectile;
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,119 @@ class CharacterStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void abilityUnlockAssignmentAndCooldownAreAtomicCharacterOwnedRevisions() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new java.util.EnumMap<>(base.stats()); stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12));
|
||||
var live = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
|
||||
BigInteger.valueOf(2), base.health(), base.learned(), base.loadout(), base.cooldowns(), base.restrictedKills(), false, null, null);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
storage.create(live).join();
|
||||
var unlocked = storage.unlockAbility(live.id(), live.revision(), "test_bolt",
|
||||
java.util.Map.of(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE).join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, unlocked.status());
|
||||
var learned = unlocked.record().orElseThrow();
|
||||
assertEquals(java.util.Set.of("test_bolt"), learned.learned());
|
||||
assertEquals(BigInteger.ONE, learned.unspentPoints());
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.ALREADY_LEARNED,
|
||||
storage.unlockAbility(live.id(), learned.revision(), "test_bolt", java.util.Map.of(), BigInteger.ONE).join().status());
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.STALE,
|
||||
storage.unlockAbility(live.id(), live.revision(), "other", java.util.Map.of(), BigInteger.ONE).join().status());
|
||||
|
||||
var assigned = storage.assignAbility(live.id(), learned.revision(), "test_bolt", 4).join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, assigned.status());
|
||||
assertEquals("test_bolt", assigned.record().orElseThrow().loadout().get(4));
|
||||
var moved = storage.assignAbility(live.id(), assigned.record().orElseThrow().revision(), "test_bolt", 1).join();
|
||||
assertEquals("", moved.record().orElseThrow().loadout().get(4));
|
||||
assertEquals("test_bolt", moved.record().orElseThrow().loadout().get(1));
|
||||
|
||||
var ready = java.time.Instant.parse("2026-09-20T20:00:00Z");
|
||||
var cooldown = storage.commitAbilityCooldown(live.id(), moved.record().orElseThrow().revision(), "test_bolt", ready).join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, cooldown.status());
|
||||
assertEquals(ready, cooldown.record().orElseThrow().cooldowns().get("test_bolt"));
|
||||
assertEquals(cooldown.record().orElseThrow(), new YamlCharacterRepository(data).load(live.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void abilityUnlockRefusesMissingRequirementsAndPointsWithoutChangingSavedState() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var live = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
storage.create(live).join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.REQUIREMENTS,
|
||||
storage.unlockAbility(live.id(), live.revision(), "test_bolt",
|
||||
java.util.Map.of(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE).join().status());
|
||||
assertEquals(live, repository.load(live.id()).orElseThrow());
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.NOT_LEARNED,
|
||||
storage.assignAbility(live.id(), live.revision(), "test_bolt", 0).join().status());
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.NOT_LEARNED,
|
||||
storage.commitAbilityCooldown(live.id(), live.revision(), "test_bolt", java.time.Instant.now()).join().status());
|
||||
}
|
||||
|
||||
var noPointsStats = new java.util.EnumMap<>(live.stats());
|
||||
noPointsStats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12));
|
||||
var noPoints = new CharacterRecord(UUID.randomUUID(), live.name(), BigInteger.ZERO, noPointsStats, live.level(),
|
||||
live.experience(), BigInteger.ZERO, live.health(), live.learned(), live.loadout(), live.cooldowns(),
|
||||
live.restrictedKills(), false, null, null);
|
||||
repository.create(noPoints);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.NO_POINTS,
|
||||
storage.unlockAbility(noPoints.id(), noPoints.revision(), "test_bolt",
|
||||
java.util.Map.of(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE).join().status());
|
||||
assertEquals(noPoints, repository.load(noPoints.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void abilityCooldownRefusesBeforeAndAllowsAtTheExactPersistedDeadline() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var learned = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
|
||||
base.experience(), base.unspentPoints(), base.health(), java.util.Set.of("fireball"),
|
||||
java.util.List.of("fireball", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
|
||||
repository.create(learned);
|
||||
var now = new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.parse("2026-09-19T20:00:00Z"));
|
||||
var clock = new Clock() {
|
||||
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
|
||||
@Override public Clock withZone(java.time.ZoneId zone) { return this; }
|
||||
@Override public java.time.Instant instant() { return now.get(); }
|
||||
};
|
||||
try (var storage = new CharacterStorage(repository, clock)) {
|
||||
var first = storage.commitAbilityCooldown(learned.id(), learned.revision(), "fireball", now.get().plusSeconds(4)).join();
|
||||
now.set(now.get().plusMillis(3999));
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.COOLDOWN,
|
||||
storage.commitAbilityCooldown(learned.id(), first.record().orElseThrow().revision(), "fireball", now.get().plusSeconds(4)).join().status());
|
||||
now.set(java.time.Instant.parse("2026-09-19T20:00:04Z"));
|
||||
var exact = storage.commitAbilityCooldown(learned.id(), first.record().orElseThrow().revision(), "fireball",
|
||||
now.get().plusSeconds(4)).join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, exact.status());
|
||||
assertEquals(java.time.Instant.parse("2026-09-19T20:00:08Z"), exact.record().orElseThrow().cooldowns().get("fireball"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAbilityUnlockSaveLeavesPointsAndLearnedStateUnchangedForRetry() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
repository.create(base);
|
||||
var failing = new YamlCharacterRepository(data, (source, target) -> { throw new IOException("simulated unlock failure"); });
|
||||
try (var storage = new CharacterStorage(failing, Clock.systemUTC())) {
|
||||
assertThrows(CompletionException.class, () -> storage.unlockAbility(base.id(), base.revision(), "test_focus",
|
||||
java.util.Map.of(), BigInteger.ONE).join());
|
||||
}
|
||||
assertEquals(base, repository.load(base.id()).orElseThrow());
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var retry = storage.unlockAbility(base.id(), base.revision(), "test_focus", java.util.Map.of(), BigInteger.ONE).join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, retry.status());
|
||||
assertTrue(retry.record().orElseThrow().learned().contains("test_focus"));
|
||||
assertEquals(base.unspentPoints().subtract(BigInteger.ONE), retry.record().orElseThrow().unspentPoints());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRevivalSaveLeavesDeadStateRetryableAndShutdownRejectsNewWork() throws Exception {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
|
||||
Reference in new issue
Block a user