Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63da22e37d | ||
|
|
b2e8da536a | ||
|
|
c77246321c | ||
|
|
438d6acfa4 | ||
|
|
e74f51cc58 | ||
|
|
1361ba3fb2 | ||
|
|
27f38818ae | ||
|
|
624deb938d | ||
|
|
232b2a190d | ||
|
|
74e717c6ca | ||
|
|
644b343fc9 | ||
|
|
ba69a55124 | ||
|
|
db35c28f0b | ||
|
|
636ff9f076 | ||
|
|
7158817726 | ||
|
|
8ee0d98599 | ||
|
|
539900eb14 |
No files matched your search
@@ -2,6 +2,8 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore: [main]
|
||||
tags-ignore: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -21,7 +21,7 @@ import java.time.Clock;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Crafting, native fantasy sessions, books, parties and names; progression actions and spells remain separate. */
|
||||
/** Crafting, native fantasy sessions, books, parties, names and permanent-stat progression. */
|
||||
public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private CharacterStorage characters;
|
||||
private ManualCraftingController crafting;
|
||||
@@ -44,6 +44,26 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.session.AdventureModeLease gameModes;
|
||||
private games.dmg.fantasystick.session.SessionVitals vitals;
|
||||
private games.dmg.fantasystick.session.SessionVisuals sessionVisuals;
|
||||
private games.dmg.fantasystick.combat.BasicAttack basicAttack;
|
||||
private games.dmg.fantasystick.party.CombatExperience combatExperience;
|
||||
private games.dmg.fantasystick.ability.AbilityRegistry abilityRegistry;
|
||||
private games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
|
||||
private games.dmg.fantasystick.ability.Fireball fireball;
|
||||
private games.dmg.fantasystick.ability.FrostShard frostShard;
|
||||
private games.dmg.fantasystick.persistence.FrostShardSettings frostShardSettings;
|
||||
private games.dmg.fantasystick.ability.Longbow longbow;
|
||||
private games.dmg.fantasystick.persistence.LongbowSettings longbowSettings;
|
||||
private games.dmg.fantasystick.ability.Quickstep quickstep;
|
||||
private games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings;
|
||||
private games.dmg.fantasystick.ability.SecondWind secondWind;
|
||||
private games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings;
|
||||
private games.dmg.fantasystick.ability.CombatMitigation combatMitigation;
|
||||
private games.dmg.fantasystick.ability.StoneSkin stoneSkin;
|
||||
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
|
||||
private games.dmg.fantasystick.ability.GreatSword greatSword;
|
||||
private games.dmg.fantasystick.ability.Revive revive;
|
||||
private games.dmg.fantasystick.persistence.RevivalStore revivals;
|
||||
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@Override public void onEnable() {
|
||||
@@ -67,9 +87,20 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var adventure = games.dmg.fantasystick.persistence.AdventureSettings.validate(getConfig().get("adventure-mode"));
|
||||
adventureSettings = new games.dmg.fantasystick.persistence.AdventureSettings(getDataFolder().toPath(), adventure);
|
||||
var switchDelay = switchDelay();
|
||||
var defaults = ProgressionRules.defaults();
|
||||
var rules = new ProgressionRules(new java.math.BigInteger(getConfig().getString("starting-points", "40")),
|
||||
defaults.xpPerLevel(), defaults.maximumLevel());
|
||||
var rules = progressionRules();
|
||||
var rewardRules = combatRewardRules();
|
||||
var fireballSettings = fireballSettings();
|
||||
var greatSwordSettings = greatSwordSettings();
|
||||
var frostShardConfiguration = frostShardConfiguration();
|
||||
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
|
||||
var longbowConfiguration = longbowConfiguration();
|
||||
var longbowInitial = games.dmg.fantasystick.persistence.LongbowSettings.parse(longbowConfiguration);
|
||||
var quickstepConfiguration = quickstepConfiguration();
|
||||
var quickstepInitial = games.dmg.fantasystick.persistence.QuickstepSettings.parse(quickstepConfiguration);
|
||||
var secondWindConfiguration = secondWindConfiguration();
|
||||
var secondWindInitial = games.dmg.fantasystick.persistence.SecondWindSettings.parse(secondWindConfiguration);
|
||||
var stoneSkinConfiguration = stoneSkinConfiguration();
|
||||
var stoneSkinInitial = games.dmg.fantasystick.persistence.StoneSkinSettings.parse(stoneSkinConfiguration);
|
||||
var factory = new CharacterFactory();
|
||||
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
|
||||
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
|
||||
@@ -108,25 +139,83 @@ 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);
|
||||
frostShardSettings = new games.dmg.fantasystick.persistence.FrostShardSettings(
|
||||
getDataFolder().toPath(), frostShardConfiguration);
|
||||
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial);
|
||||
longbowSettings = new games.dmg.fantasystick.persistence.LongbowSettings(
|
||||
getDataFolder().toPath(), longbowConfiguration);
|
||||
longbow = new games.dmg.fantasystick.ability.Longbow(longbowInitial);
|
||||
quickstepSettings = new games.dmg.fantasystick.persistence.QuickstepSettings(
|
||||
getDataFolder().toPath(), quickstepConfiguration);
|
||||
quickstep = new games.dmg.fantasystick.ability.Quickstep(quickstepInitial,
|
||||
event -> getServer().getPluginManager().callEvent(event));
|
||||
secondWindSettings = new games.dmg.fantasystick.persistence.SecondWindSettings(
|
||||
getDataFolder().toPath(), secondWindConfiguration);
|
||||
secondWind = new games.dmg.fantasystick.ability.SecondWind(secondWindInitial, characters, sessions, nextTick,
|
||||
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read);
|
||||
stoneSkinSettings = new games.dmg.fantasystick.persistence.StoneSkinSettings(
|
||||
getDataFolder().toPath(), stoneSkinConfiguration);
|
||||
combatMitigation = new games.dmg.fantasystick.ability.CombatMitigation();
|
||||
stoneSkin = new games.dmg.fantasystick.ability.StoneSkin(stoneSkinInitial, combatMitigation);
|
||||
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
|
||||
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
|
||||
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
|
||||
(size, title) -> getServer().createInventory(null, size, title),
|
||||
problem -> getLogger().log(Level.WARNING, "Revive transition failed", problem));
|
||||
abilityRegistry.register(fireball.definition());
|
||||
abilityRegistry.register(frostShard.definition());
|
||||
abilityRegistry.register(longbow.definition());
|
||||
abilityRegistry.register(quickstep.definition());
|
||||
abilityRegistry.register(secondWind.definition());
|
||||
abilityRegistry.register(stoneSkin.definition());
|
||||
abilityRegistry.register(greatSword.definition());
|
||||
abilityRegistry.register(revive.definition());
|
||||
sessions.attachAbilityRegistry(abilityRegistry);
|
||||
vitals = new games.dmg.fantasystick.session.SessionVitals();
|
||||
sessions.attachVitals(vitals);
|
||||
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
|
||||
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
|
||||
var player = getServer().getPlayer(id);
|
||||
return player != null && player.isOnline();
|
||||
});
|
||||
partyEvents = new games.dmg.fantasystick.party.PartyEvents(parties);
|
||||
partyCombat = new games.dmg.fantasystick.party.PartyCombat(parties, sessions);
|
||||
books = new games.dmg.fantasystick.book.CharacterBookController(characters, rules, nextTick,
|
||||
combatExperience = new games.dmg.fantasystick.party.CombatExperience(characters, rules, rewardRules,
|
||||
sessions, Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
|
||||
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()));
|
||||
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());
|
||||
});
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, books);
|
||||
}, 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);
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings,
|
||||
frostShardSettings, frostShard, longbowSettings, longbow,
|
||||
quickstepSettings, quickstep, secondWindSettings, secondWind,
|
||||
stoneSkinSettings, stoneSkin, abilityRegistry);
|
||||
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
|
||||
command.setExecutor(admin);
|
||||
command.setTabCompleter(admin);
|
||||
@@ -138,6 +227,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(partyEvents, this);
|
||||
getServer().getPluginManager().registerEvents(partyCombat, this);
|
||||
partyCombat.install(this);
|
||||
getServer().getPluginManager().registerEvents(combatExperience, this);
|
||||
combatExperience.install(this);
|
||||
getServer().getPluginManager().registerEvents(fireball, this);
|
||||
getServer().getPluginManager().registerEvents(frostShard, this);
|
||||
getServer().getPluginManager().registerEvents(longbow, this);
|
||||
getServer().getPluginManager().registerEvents(quickstep, this);
|
||||
getServer().getPluginManager().registerEvents(combatMitigation, this);
|
||||
getServer().getPluginManager().registerEvents(greatSword, this);
|
||||
getServer().getPluginManager().registerEvents(revive, this);
|
||||
getServer().getPluginManager().registerEvents(localBasicAttack, this);
|
||||
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
getServer().getPluginManager().registerEvents(gameModes, this);
|
||||
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
|
||||
@@ -145,7 +245,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
sessionVisuals.install(this);
|
||||
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
|
||||
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
|
||||
for (var player : getServer().getOnlinePlayers()) sessions.arrive(player);
|
||||
for (var player : getServer().getOnlinePlayers()) { sessions.arrive(player); revive.reconcile(player); }
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
try { onDisable(); }
|
||||
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
|
||||
@@ -175,15 +275,62 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingModes = gameModes; gameModes = null;
|
||||
var closingVitals = vitals; vitals = null;
|
||||
var closingVisuals = sessionVisuals; sessionVisuals = null;
|
||||
var closingBasicAttack = basicAttack; basicAttack = null;
|
||||
var closingCombatExperience = combatExperience; combatExperience = null;
|
||||
var closingAbilityEngine = abilityEngine; abilityEngine = null;
|
||||
var closingFireball = fireball; fireball = null;
|
||||
var closingFrostShard = frostShard; frostShard = null;
|
||||
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
|
||||
var closingLongbow = longbow; longbow = null;
|
||||
var closingLongbowSettings = longbowSettings; longbowSettings = null;
|
||||
var closingQuickstep = quickstep; quickstep = null;
|
||||
var closingQuickstepSettings = quickstepSettings; quickstepSettings = null;
|
||||
secondWind = null;
|
||||
var closingStoneSkin = stoneSkin; stoneSkin = null;
|
||||
var closingCombatMitigation = combatMitigation; combatMitigation = null;
|
||||
var closingStoneSkinSettings = stoneSkinSettings; stoneSkinSettings = null;
|
||||
var closingSecondWindSettings = secondWindSettings; secondWindSettings = null;
|
||||
var closingGreatSword = greatSword; greatSword = null;
|
||||
var closingRevive = revive; revive = null;
|
||||
var closingRevivals = revivals; revivals = null;
|
||||
abilityRegistry = null;
|
||||
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
|
||||
// Retire client resources independently of successful ordinary-equipment restoration.
|
||||
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
|
||||
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
|
||||
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
|
||||
if (closingSettings != null) {
|
||||
configurationDrain = closingSettings.drained();
|
||||
cleanup("configuration", closingSettings::close);
|
||||
}
|
||||
if (closingSettings != null) cleanup("configuration", closingSettings::close);
|
||||
if (closingFrostSettings != null) cleanup("Frost Shard configuration", closingFrostSettings::close);
|
||||
if (closingLongbowSettings != null) cleanup("Longbow configuration", closingLongbowSettings::close);
|
||||
if (closingQuickstepSettings != null) cleanup("Quickstep configuration", closingQuickstepSettings::close);
|
||||
if (closingSecondWindSettings != null) cleanup("Second Wind configuration", closingSecondWindSettings::close);
|
||||
if (closingStoneSkinSettings != null) cleanup("Stone Skin configuration", closingStoneSkinSettings::close);
|
||||
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
|
||||
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSettings.drained().toCompletableFuture(),
|
||||
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingFrostSettings.drained().toCompletableFuture(),
|
||||
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingLongbowSettings.drained().toCompletableFuture(),
|
||||
closingQuickstepSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingQuickstepSettings.drained().toCompletableFuture(),
|
||||
closingSecondWindSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSecondWindSettings.drained().toCompletableFuture(),
|
||||
closingStoneSkinSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingStoneSkinSettings.drained().toCompletableFuture());
|
||||
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 (closingFrostShard != null) cleanup("Frost Shard", closingFrostShard::close);
|
||||
if (closingLongbow != null) cleanup("Longbow", closingLongbow::close);
|
||||
if (closingQuickstep != null) cleanup("Quickstep", closingQuickstep::close);
|
||||
if (closingStoneSkin != null) cleanup("Stone Skin", closingStoneSkin::close);
|
||||
if (closingCombatMitigation != null) cleanup("combat mitigation", closingCombatMitigation::close);
|
||||
if (closingGreatSword != null) cleanup("Great Sword", closingGreatSword::close);
|
||||
if (closingRevive != null) cleanup("Revive", closingRevive::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);
|
||||
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
|
||||
@@ -195,6 +342,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingChat);
|
||||
unregister(closingPartyEvents);
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingCombatExperience);
|
||||
unregister(closingFireball);
|
||||
unregister(closingFrostShard);
|
||||
unregister(closingLongbow);
|
||||
unregister(closingQuickstep);
|
||||
unregister(closingCombatMitigation);
|
||||
unregister(closingGreatSword);
|
||||
unregister(closingRevive);
|
||||
unregister(closingBasicAttack);
|
||||
unregister(closingNutrition);
|
||||
unregister(closingBooks);
|
||||
unregister(closingEvents);
|
||||
unregister(closingCrafting);
|
||||
@@ -205,6 +362,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
|
||||
});
|
||||
if (closingCharacters != null) cleanup("character storage", closingCharacters::close);
|
||||
if (closingRevivals != null) cleanup("revival storage", closingRevivals::close);
|
||||
// Never await IO on the server thread or close receipts ahead of accepted preparations.
|
||||
if (closingReceipts != null) cleanup("equipment receipt drain", () -> {
|
||||
if (closingSessions == null) closingReceipts.close();
|
||||
@@ -225,6 +383,104 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private ProgressionRules progressionRules() {
|
||||
try {
|
||||
var starting = new java.math.BigInteger(getConfig().getString("starting-points", "40"));
|
||||
var threshold = new java.math.BigInteger(getConfig().getString("xp-per-level", "100"));
|
||||
var maximum = new java.math.BigInteger(getConfig().getString("maximum-level", "30")).intValueExact();
|
||||
return new ProgressionRules(starting, threshold, maximum);
|
||||
} catch (ArithmeticException invalid) {
|
||||
throw new IllegalArgumentException("maximum-level exceeds the supported integer range", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules() {
|
||||
try {
|
||||
var passive = new java.math.BigInteger(getConfig().getString("passive-xp", "1"));
|
||||
var ordinary = new java.math.BigInteger(getConfig().getString("ordinary-xp", "10"));
|
||||
var boss = new java.math.BigInteger(getConfig().getString("boss-xp", "100"));
|
||||
var spacing = new java.math.BigDecimal(getConfig().getString("restricted-kill-spacing-blocks", "3"));
|
||||
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, boss, spacing);
|
||||
} catch (NumberFormatException invalid) {
|
||||
throw new IllegalArgumentException("Combat reward settings must be decimal numbers", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private 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.util.Map<String, String> frostShardConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.FrostShardSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> longbowConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.LongbowSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.LongbowSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.LongbowSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> quickstepConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.QuickstepSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.QuickstepSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.QuickstepSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> secondWindConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.SecondWindSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.SecondWindSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.SecondWindSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> stoneSkinConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.StoneSkinSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.StoneSkinSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.StoneSkinSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
|
||||
try {
|
||||
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
|
||||
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "1.2"));
|
||||
var recovery = java.time.Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
|
||||
return new games.dmg.fantasystick.ability.GreatSword.Settings(base, recovery);
|
||||
} catch (ArithmeticException | NumberFormatException invalid) {
|
||||
throw new IllegalArgumentException("Great Sword 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,40 @@
|
||||
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,
|
||||
EXCEPTIONAL_NONCOMBAT_SELECTION }
|
||||
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,9 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
/** Focused native effect invoked only after shared persistence, cooldown and target authorization. */
|
||||
@FunctionalInterface
|
||||
public interface AbilityEffect {
|
||||
/** Effect-specific initial eligibility checked before the shared cooldown commit. */
|
||||
default boolean accepts(games.dmg.fantasystick.session.ControlInput input) { return true; }
|
||||
void activate(AbilityExecution execution);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
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));
|
||||
}
|
||||
|
||||
public CompletableFuture<Mutation> unlearn(ControlInput input, String ability, 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.unlearnAbility(input.session().character(), expectedRevision, ability))
|
||||
.thenApply(mutation -> {
|
||||
if (mutation.record() != null && !mutation.record().learned().contains(ability)) {
|
||||
remove(input.session().character(), ability);
|
||||
}
|
||||
return mutation;
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
boolean accepted;
|
||||
try { accepted = eligible(definition.targetPolicy(), input, input.target()) && definition.effect().accepts(input); }
|
||||
catch (RuntimeException invalid) {
|
||||
report(input.session().character(), definition.id(), invalid);
|
||||
input.player().sendMessage("That target could not be validated for " + definition.name() + "."); return;
|
||||
}
|
||||
if (!accepted) {
|
||||
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();
|
||||
case EXCEPTIONAL_NONCOMBAT_SELECTION -> target == null || 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
|
||||
|| definition.targetPolicy() == AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION
|
||||
|| 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,36 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace one registered definition after an acknowledged runtime configuration save. */
|
||||
public void replace(AbilityDefinition definition) {
|
||||
Objects.requireNonNull(definition);
|
||||
definitions.compute(definition.id(), (id, existing) -> {
|
||||
if (existing == null) throw new IllegalArgumentException("Unknown ability ID: " + id);
|
||||
return definition;
|
||||
});
|
||||
}
|
||||
|
||||
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,77 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
|
||||
/** Internal strongest-reduction stage shared by Fantasy Stick protection effects. */
|
||||
public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
private final class Grant implements AutoCloseable {
|
||||
final AbilityExecution execution; final Player player; final BigDecimal percent; boolean closed;
|
||||
Grant(AbilityExecution execution, Player player, BigDecimal percent) {
|
||||
this.execution = execution; this.player = player; this.percent = percent;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
var owned = grants.get(player.getUniqueId());
|
||||
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(player.getUniqueId()); }
|
||||
}
|
||||
}
|
||||
private record Prevention(Grant grant, BigDecimal amount) { }
|
||||
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
|
||||
private final Map<EntityDamageEvent, Prevention> pending = new IdentityHashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent);
|
||||
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
|
||||
throw new IllegalArgumentException("Invalid combat mitigation grant");
|
||||
var grant = new Grant(execution, player, percent);
|
||||
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
|
||||
return grant;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void reduce(EntityDamageByEntityEvent event) {
|
||||
if (closed || !(event.getEntity() instanceof Player player)) return;
|
||||
Grant strongest = null;
|
||||
var owned = grants.get(player.getUniqueId());
|
||||
if (owned == null) return;
|
||||
for (var grant : List.copyOf(owned)) {
|
||||
if (grant.closed || grant.player != player || !grant.execution.stillCurrent()) continue;
|
||||
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0) strongest = grant;
|
||||
}
|
||||
double original = event.getDamage();
|
||||
if (strongest == null || !Double.isFinite(original) || original <= 0) return;
|
||||
double reduced = BigDecimal.valueOf(original)
|
||||
.multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
|
||||
if (!Double.isFinite(reduced) || reduced < 0) return;
|
||||
event.setDamage(reduced);
|
||||
BigDecimal prevented = BigDecimal.valueOf(original - reduced);
|
||||
if (prevented.signum() > 0) pending.put(event, new Prevention(strongest, prevented));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void accepted(EntityDamageEvent event) {
|
||||
var prevention = pending.remove(event);
|
||||
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !prevention.grant.execution.stillCurrent()) return;
|
||||
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
for (var owned : List.copyOf(grants.values())) for (var grant : List.copyOf(owned)) grant.close();
|
||||
grants.clear(); pending.clear();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/** Frost Shard's bounded direct projectile damage and owned Slowness adapter. */
|
||||
public final class FrostShard implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime,
|
||||
double speedPerTick, int slownessAmplifier, Duration slownessDuration) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(lifetime);
|
||||
Objects.requireNonNull(slownessDuration);
|
||||
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
|
||||
|| !Double.isFinite(speedPerTick) || speedPerTick < 0.01 || speedPerTick > 64
|
||||
|| slownessAmplifier < 0 || slownessAmplifier > 255
|
||||
|| slownessDuration.isZero() || slownessDuration.isNegative()
|
||||
|| slownessDuration.compareTo(Duration.ofDays(1)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Frost Shard settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(5), 32, Duration.ofSeconds(5),
|
||||
1, 0, Duration.ofSeconds(2));
|
||||
}
|
||||
}
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final Snowball projectile;
|
||||
final AbilityExecution execution;
|
||||
final Location origin;
|
||||
final BigInteger intelligence;
|
||||
final Settings settings;
|
||||
LivingEntity target;
|
||||
boolean accepted;
|
||||
boolean finished;
|
||||
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
|
||||
this.projectile = projectile; this.execution = execution; this.origin = origin;
|
||||
this.intelligence = intelligence; this.settings = settings;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true; projectiles.remove(projectile.getUniqueId(), this); projectile.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SlowLease implements AutoCloseable {
|
||||
private final LivingEntity target;
|
||||
private final PotionEffect previous;
|
||||
private final PotionEffect applied;
|
||||
private final long started = System.nanoTime();
|
||||
private boolean owned;
|
||||
private boolean closed;
|
||||
SlowLease(LivingEntity target, PotionEffect applied) {
|
||||
this.target = target; this.previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
|
||||
}
|
||||
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (!owned) return;
|
||||
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
|
||||
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
|
||||
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
|
||||
if (current == null || current.getAmplifier() != applied.getAmplifier()
|
||||
|| current.getDuration() > expected + 2) return;
|
||||
target.removePotionEffect(PotionEffectType.SLOWNESS);
|
||||
if (previous != null) {
|
||||
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
|
||||
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final Map<UUID, State> projectiles = new HashMap<>();
|
||||
private State damaging;
|
||||
private boolean closed;
|
||||
|
||||
public FrostShard(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("frost_shard", "Frost Shard", Material.PRISMARINE_SHARD,
|
||||
List.of("Launch a bounded frost projectile.",
|
||||
"Deals " + current.baseDamage() + " + INT modifier damage and Slowness "
|
||||
+ level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + "s."),
|
||||
"Use the equipped shard; a miss still spends the cooldown.",
|
||||
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()),
|
||||
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
|
||||
"lifetime", seconds(current.lifetime()), "projectile-speed", BigDecimal.valueOf(current.speedPerTick()),
|
||||
"slowness-amplifier", BigDecimal.valueOf(current.slownessAmplifier()),
|
||||
"slowness-duration", seconds(current.slownessDuration())),
|
||||
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
|
||||
}
|
||||
|
||||
public static BigInteger damage(BigInteger intelligence) {
|
||||
return damage(BigInteger.valueOf(4), 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 Frost Shard 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 current = settings;
|
||||
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("Frost Shard direction is unavailable");
|
||||
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(current.speedPerTick()));
|
||||
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT), current);
|
||||
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
|
||||
projectile.remove(); throw new IllegalStateException("Duplicate Frost Shard projectile identity");
|
||||
}
|
||||
execution.own(state);
|
||||
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
|
||||
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
|
||||
});
|
||||
execution.later(current.lifetime(), () -> {
|
||||
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
|
||||
});
|
||||
player.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void hit(ProjectileHitEvent event) {
|
||||
var state = projectiles.remove(event.getEntity().getUniqueId());
|
||||
if (state == null) return;
|
||||
boolean retainExecution = false;
|
||||
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;
|
||||
var current = state.settings;
|
||||
BigInteger amount = damage(current.baseDamage(), state.intelligence);
|
||||
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
|
||||
var source = DamageSource.builder(DamageType.FREEZE).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; }
|
||||
if (state.accepted && state.execution.stillCurrent()) {
|
||||
int ticks = Math.toIntExact(Math.max(1,
|
||||
(current.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
|
||||
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
|
||||
current.slownessAmplifier(), false, true, true));
|
||||
if (lease.apply()) {
|
||||
state.execution.own(lease);
|
||||
state.execution.later(current.slownessDuration(), state.execution::finish);
|
||||
retainExecution = true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
state.close();
|
||||
if (!retainExecution) 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) <= state.settings.range() * state.settings.range();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || state.accepted || 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;
|
||||
state.accepted = true;
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
private static String level(int amplifier) {
|
||||
return switch (amplifier + 1) {
|
||||
case 1 -> "I"; case 2 -> "II"; case 3 -> "III"; case 4 -> "IV"; case 5 -> "V";
|
||||
default -> Integer.toString(amplifier + 1);
|
||||
};
|
||||
}
|
||||
|
||||
private static BigDecimal seconds(Duration duration) {
|
||||
return BigDecimal.valueOf(duration.getSeconds()).add(BigDecimal.valueOf(duration.getNano(), 9))
|
||||
.stripTrailingZeros();
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
|
||||
projectiles.clear(); damaging = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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 org.bukkit.Material;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
|
||||
/** Great Sword's charge-scaled direct melee damage, replacing the contained native weapon hit. */
|
||||
public final class GreatSword implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration recovery) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(recovery);
|
||||
if (baseDamage.signum() < 0 || recovery.isNegative()
|
||||
|| recovery.compareTo(Duration.ofDays(365)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Great Sword settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(1200)); }
|
||||
}
|
||||
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution;
|
||||
final LivingEntity target;
|
||||
boolean reported;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) {
|
||||
this.execution = execution; this.target = target;
|
||||
}
|
||||
}
|
||||
|
||||
private final Settings settings;
|
||||
private Attempt damaging;
|
||||
private boolean closed;
|
||||
|
||||
public GreatSword(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
return new AbilityDefinition("great_sword", "Great Sword", Material.WOODEN_SWORD,
|
||||
List.of("Charge a contained melee strike.",
|
||||
"Fully charged: " + settings.baseDamage() + " + STR modifier damage."),
|
||||
"Attack with the equipped sword; partial charge follows the native attack curve.",
|
||||
Map.of(Stat.STR, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.ATTACK, settings.recovery(),
|
||||
Map.of("base-damage", new BigDecimal(settings.baseDamage())),
|
||||
AbilityDefinition.TargetPolicy.HOSTILE, this);
|
||||
}
|
||||
|
||||
public static BigDecimal damage(BigInteger strength, float charge) {
|
||||
return damage(BigInteger.valueOf(7), strength, charge);
|
||||
}
|
||||
|
||||
static BigDecimal damage(BigInteger base, BigInteger strength, float charge) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(strength);
|
||||
if (base.signum() < 0 || strength.signum() < 0 || !Float.isFinite(charge) || charge < 0 || charge > 1) {
|
||||
throw new IllegalArgumentException("Invalid Great Sword damage input");
|
||||
}
|
||||
BigInteger delta = strength.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);
|
||||
BigDecimal full = new BigDecimal(base.add(modifier));
|
||||
BigDecimal nativeCharge = new BigDecimal(Float.toString(charge));
|
||||
BigDecimal curve = new BigDecimal("0.2").add(new BigDecimal("0.8").multiply(nativeCharge.multiply(nativeCharge)));
|
||||
return full.multiply(curve).stripTrailingZeros();
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
return !closed && input.target() instanceof LivingEntity;
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
|
||||
BigDecimal amount = damage(settings.baseDamage(), execution.character().stats().get(Stat.STR),
|
||||
execution.input().attackStrength());
|
||||
double nativeDamage = amount.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|
||||
? Double.MAX_VALUE : amount.doubleValue();
|
||||
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
|
||||
throw new IllegalStateException("Great Sword damage is outside the native finite range");
|
||||
}
|
||||
var player = execution.input().player();
|
||||
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player)
|
||||
.withDirectEntity(player).build();
|
||||
var attempt = new Attempt(execution, target);
|
||||
damaging = attempt;
|
||||
try { target.damage(nativeDamage, source); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = damaging;
|
||||
if (closed || attempt == null || attempt.reported || event.isCancelled()
|
||||
|| event.getDamager() != attempt.execution.input().player() || event.getEntity() != attempt.target
|
||||
|| (event.getDamageSource().getCausingEntity() != null
|
||||
&& event.getDamageSource().getCausingEntity() != attempt.execution.input().player())
|
||||
|| (event.getDamageSource().getDirectEntity() != null
|
||||
&& event.getDamageSource().getDirectEntity() != attempt.execution.input().player())
|
||||
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.reported = true;
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
|
||||
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; damaging = null; }
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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.math.MathContext;
|
||||
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.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.AbstractArrow;
|
||||
import org.bukkit.entity.Arrow;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.ProjectileHitEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/** Longbow's ammunition-free native draw and bounded custom-arrow damage adapter. */
|
||||
public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
private static final BigInteger MAX_NATIVE_BASE = BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger();
|
||||
|
||||
public record Settings(BigInteger baseDamage, Duration recovery, int minimumDrawTicks, int fullDrawTicks,
|
||||
double range, Duration lifetime, double fullSpeedPerTick) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(recovery); Objects.requireNonNull(lifetime);
|
||||
if (baseDamage.signum() < 0 || baseDamage.compareTo(MAX_NATIVE_BASE) > 0
|
||||
|| recovery.compareTo(Duration.ofSeconds(1)) < 0
|
||||
|| recovery.compareTo(Duration.ofDays(365)) > 0
|
||||
|| minimumDrawTicks < 1 || minimumDrawTicks > 72_000
|
||||
|| fullDrawTicks < minimumDrawTicks || fullDrawTicks > 72_000
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 1024
|
||||
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0
|
||||
|| !Double.isFinite(fullSpeedPerTick) || fullSpeedPerTick < 0.01 || fullSpeedPerTick > 64) {
|
||||
throw new IllegalArgumentException("Invalid Longbow settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(5), Duration.ofSeconds(1), 3, 20, 64,
|
||||
Duration.ofSeconds(10), 3);
|
||||
}
|
||||
}
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final Arrow arrow;
|
||||
final AbilityExecution execution;
|
||||
final Location origin;
|
||||
final Settings settings;
|
||||
final BigDecimal damage;
|
||||
LivingEntity target;
|
||||
boolean accepted;
|
||||
boolean finished;
|
||||
State(Arrow arrow, AbilityExecution execution, Location origin, Settings settings, BigDecimal damage) {
|
||||
this.arrow = arrow; this.execution = execution; this.origin = origin; this.settings = settings; this.damage = damage;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true; arrows.remove(arrow.getUniqueId(), this); arrow.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final Map<UUID, State> arrows = new HashMap<>();
|
||||
private State damaging;
|
||||
private boolean closed;
|
||||
|
||||
public Longbow(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("longbow", "Longbow", Material.BOW,
|
||||
List.of("Draw and release an ammunition-free owned arrow.",
|
||||
"A full draw deals " + current.baseDamage() + " + DEX modifier damage."),
|
||||
"Draw by holding use; release after at least " + current.minimumDrawTicks() + " ticks.",
|
||||
Map.of(Stat.DEX, BigInteger.valueOf(14)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.DRAW_RELEASE, current.recovery(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()),
|
||||
"minimum-draw-ticks", BigDecimal.valueOf(current.minimumDrawTicks()),
|
||||
"full-draw-ticks", BigDecimal.valueOf(current.fullDrawTicks()),
|
||||
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
|
||||
"lifetime", seconds(current.lifetime()),
|
||||
"full-projectile-speed", BigDecimal.valueOf(current.fullSpeedPerTick()).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
return input.gesture() == ControlInput.Gesture.DRAW_RELEASE && input.heldTicks() >= settings.minimumDrawTicks();
|
||||
}
|
||||
|
||||
public static BigDecimal power(int heldTicks, int fullDrawTicks) {
|
||||
if (heldTicks < 0 || fullDrawTicks < 1) throw new IllegalArgumentException("Invalid Longbow draw");
|
||||
var x = BigDecimal.valueOf(heldTicks).divide(BigDecimal.valueOf(fullDrawTicks), MathContext.DECIMAL128);
|
||||
var power = x.multiply(x, MathContext.DECIMAL128).add(x.multiply(BigDecimal.TWO), MathContext.DECIMAL128)
|
||||
.divide(BigDecimal.valueOf(3), MathContext.DECIMAL128);
|
||||
return power.min(BigDecimal.ONE);
|
||||
}
|
||||
|
||||
public static BigInteger fullDamage(BigInteger dexterity) {
|
||||
return fullDamage(BigInteger.valueOf(5), dexterity);
|
||||
}
|
||||
|
||||
static BigInteger fullDamage(BigInteger base, BigInteger dexterity) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(dexterity);
|
||||
if (base.signum() < 0 || dexterity.signum() < 0) throw new IllegalArgumentException("Invalid Longbow damage input");
|
||||
BigInteger delta = dexterity.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;
|
||||
Settings shot = from(execution.definition());
|
||||
BigDecimal power = power(execution.input().heldTicks(), shot.fullDrawTicks());
|
||||
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("Longbow direction is unavailable");
|
||||
double speed = BigDecimal.valueOf(shot.fullSpeedPerTick()).multiply(power, MathContext.DECIMAL128).doubleValue();
|
||||
var arrow = player.launchProjectile(Arrow.class, direction.normalize().multiply(speed));
|
||||
arrow.setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
|
||||
arrow.setDamage(0); arrow.setKnockbackStrength(0); arrow.setPierceLevel(0); arrow.setCritical(false);
|
||||
var damage = new BigDecimal(fullDamage(shot.baseDamage(), execution.character().stats().get(Stat.DEX)))
|
||||
.multiply(power, MathContext.DECIMAL128);
|
||||
var state = new State(arrow, execution, origin, shot, damage);
|
||||
if (arrows.putIfAbsent(arrow.getUniqueId(), state) != null) {
|
||||
arrow.remove(); throw new IllegalStateException("Duplicate Longbow arrow identity");
|
||||
}
|
||||
execution.own(state);
|
||||
long rangeTicks = Math.max(1, (long) Math.ceil(shot.range() / speed));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
|
||||
});
|
||||
execution.later(shot.lifetime(), () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void containNativeDamage(EntityDamageByEntityEvent event) {
|
||||
if (!(event.getDamager() instanceof Arrow arrow)) return;
|
||||
var state = arrows.get(arrow.getUniqueId());
|
||||
if (state != null && damaging != state) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void hit(ProjectileHitEvent event) {
|
||||
var state = arrows.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;
|
||||
double nativeDamage = state.damage.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|
||||
? Double.MAX_VALUE : state.damage.doubleValue();
|
||||
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) return;
|
||||
var source = DamageSource.builder(DamageType.ARROW).withCausingEntity(state.execution.input().player())
|
||||
.withDirectEntity(state.arrow).withDamageLocation(state.arrow.getLocation()).build();
|
||||
state.target = target; damaging = state;
|
||||
try { target.damage(nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
} finally {
|
||||
state.close(); state.execution.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean inRange(State state, Location current) {
|
||||
return current.getWorld() != null && state.origin.getWorld() != null
|
||||
&& current.getWorld().equals(state.origin.getWorld())
|
||||
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage())) return;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct != state.arrow && (!(event instanceof EntityDamageByEntityEvent byEntity)
|
||||
|| byEntity.getDamager() != state.arrow)) return;
|
||||
state.accepted = true;
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var p = definition.parameters();
|
||||
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(),
|
||||
p.get("minimum-draw-ticks").intValueExact(), p.get("full-draw-ticks").intValueExact(),
|
||||
p.get("range").doubleValue(), duration(p.get("lifetime")),
|
||||
p.get("full-projectile-speed").doubleValue());
|
||||
}
|
||||
|
||||
private static Duration duration(BigDecimal seconds) {
|
||||
return Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
|
||||
}
|
||||
private static BigDecimal seconds(Duration duration) {
|
||||
return BigDecimal.valueOf(duration.getSeconds()).add(BigDecimal.valueOf(duration.getNano(), 9)).stripTrailingZeros();
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (var state : List.copyOf(arrows.values())) state.execution.finish();
|
||||
arrows.clear(); damaging = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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 java.util.function.Consumer;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerVelocityEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/** Quickstep's grounded, collision-respecting, owned horizontal velocity adapter. */
|
||||
public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(Duration cooldown, double distance, double speedPerTick) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(distance) || distance <= 0 || distance > 64
|
||||
|| !Double.isFinite(speedPerTick) || speedPerTick < 0.01 || speedPerTick > 8) {
|
||||
throw new IllegalArgumentException("Invalid Quickstep settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(Duration.ofSeconds(5), 4, 1); }
|
||||
}
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final AbilityExecution execution;
|
||||
final Player player;
|
||||
final Location origin;
|
||||
final Vector horizontal;
|
||||
final Settings settings;
|
||||
boolean owned = true;
|
||||
boolean closed;
|
||||
State(AbilityExecution execution, Player player, Location origin, Vector horizontal, Settings settings) {
|
||||
this.execution = execution; this.player = player; this.origin = origin;
|
||||
this.horizontal = horizontal; this.settings = settings;
|
||||
}
|
||||
boolean stillOwns(Vector current) {
|
||||
var horizontalCurrent = new Vector(current.getX(), 0, current.getZ());
|
||||
double magnitude = horizontalCurrent.length();
|
||||
if (magnitude < 1.0e-6) return true;
|
||||
return magnitude <= horizontal.length() * 1.05 + 1.0e-6
|
||||
&& horizontalCurrent.clone().normalize().dot(horizontal.clone().normalize()) >= 0.99;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true; dashes.remove(player.getUniqueId(), this);
|
||||
if (!owned) return;
|
||||
Vector current = player.getVelocity();
|
||||
if (!stillOwns(current)) return;
|
||||
player.setVelocity(new Vector(0, current.getY(), 0));
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final Consumer<PlayerVelocityEvent> velocityEvents;
|
||||
private final Map<UUID, State> dashes = new HashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
public Quickstep(Settings settings, Consumer<PlayerVelocityEvent> velocityEvents) {
|
||||
this.settings = Objects.requireNonNull(settings); this.velocityEvents = Objects.requireNonNull(velocityEvents);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("quickstep", "Quickstep", Material.RABBIT_FOOT,
|
||||
List.of("Dash forward with native horizontal movement.",
|
||||
"Travel up to " + decimal(current.distance()) + " blocks while obstacles remain authoritative."),
|
||||
"Use while grounded, on foot, and not flying or gliding.",
|
||||
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("distance", BigDecimal.valueOf(current.distance()).stripTrailingZeros(),
|
||||
"speed", BigDecimal.valueOf(current.speedPerTick()).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.SELF, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
var player = input.player();
|
||||
if (!player.isOnGround() || player.isFlying() || player.isGliding() || player.isInsideVehicle()) return false;
|
||||
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0);
|
||||
return finite(direction) && direction.lengthSquared() > 1.0e-12;
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = from(execution.definition());
|
||||
var player = execution.input().player();
|
||||
if (!accepts(execution.input())) { execution.finish(); return; }
|
||||
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0).normalize();
|
||||
Vector before = player.getVelocity();
|
||||
Vector horizontal = direction.multiply(current.speedPerTick());
|
||||
var proposed = new Vector(horizontal.getX(), before.getY(), horizontal.getZ());
|
||||
var event = new PlayerVelocityEvent(player, proposed.clone());
|
||||
velocityEvents.accept(event);
|
||||
if (event.isCancelled() || !finite(event.getVelocity())) { execution.finish(); return; }
|
||||
Vector accepted = event.getVelocity().clone(); accepted.setY(before.getY());
|
||||
Vector acceptedHorizontal = new Vector(accepted.getX(), 0, accepted.getZ());
|
||||
if (acceptedHorizontal.lengthSquared() < 1.0e-12) { execution.finish(); return; }
|
||||
var state = new State(execution, player, player.getLocation().clone(), acceptedHorizontal, current);
|
||||
var previous = dashes.put(player.getUniqueId(), state);
|
||||
if (previous != null) previous.execution.finish();
|
||||
execution.own(state);
|
||||
player.setVelocity(accepted);
|
||||
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), execution::finish);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void moved(PlayerMoveEvent event) {
|
||||
var state = dashes.get(event.getPlayer().getUniqueId());
|
||||
if (state == null || state.player != event.getPlayer()) return;
|
||||
var to = event.getTo();
|
||||
if (to == null || !state.execution.stillCurrent()) { state.execution.finish(); return; }
|
||||
Vector current = state.player.getVelocity();
|
||||
if (!state.stillOwns(current)) {
|
||||
state.owned = false; state.execution.finish(); return;
|
||||
}
|
||||
double dx = to.getX() - state.origin.getX(); double dz = to.getZ() - state.origin.getZ();
|
||||
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()
|
||||
|| new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
|
||||
state.execution.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean finite(Vector vector) {
|
||||
return Double.isFinite(vector.getX()) && Double.isFinite(vector.getY()) && Double.isFinite(vector.getZ());
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
return new Settings(definition.cooldown(), definition.parameters().get("distance").doubleValue(),
|
||||
definition.parameters().get("speed").doubleValue());
|
||||
}
|
||||
private static BigDecimal decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros(); }
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (var state : List.copyOf(dashes.values())) state.execution.finish();
|
||||
dashes.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.items.TailBooks;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/** Revive target selection, contained popup and proof-based Tail conversion. */
|
||||
public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
public interface PopupFactory { Inventory create(int size, Component title); }
|
||||
private static final Duration DEATH_AGE = Duration.ofHours(24);
|
||||
private static final Duration VIEW_TIME = Duration.ofMinutes(5);
|
||||
private static final double RANGE = 12;
|
||||
private record Candidate(int slot, ItemStack captured, TailBooks.Identity identity) { }
|
||||
private record Choice(Candidate candidate, CharacterRecord record) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final RevivalStore reservations;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final PopupFactory popups;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final TailBooks tails = new TailBooks();
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private final Map<Inventory, View> inventories = new IdentityHashMap<>();
|
||||
private final Map<UUID, View> targets = new LinkedHashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
private final class View implements AutoCloseable {
|
||||
final AbilityExecution execution;
|
||||
final Player target;
|
||||
final Inventory inventory;
|
||||
final Map<Integer, Choice> choices;
|
||||
boolean selecting;
|
||||
boolean finished;
|
||||
View(AbilityExecution execution, Player target, Inventory inventory, Map<Integer, Choice> choices) {
|
||||
this.execution = execution; this.target = target; this.inventory = inventory; this.choices = choices;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
inventories.remove(inventory, this); targets.remove(target.getUniqueId(), this);
|
||||
inventory.clear();
|
||||
try { target.closeInventory(); } catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
}
|
||||
|
||||
public Revive(CharacterStorage characters, RevivalStore reservations, Clock clock, Consumer<Runnable> nextTick,
|
||||
PopupFactory popups, Consumer<Throwable> failures) {
|
||||
this.characters = Objects.requireNonNull(characters); this.reservations = Objects.requireNonNull(reservations);
|
||||
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.popups = Objects.requireNonNull(popups); this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
return new AbilityDefinition("revive", "Revive", Material.ECHO_SHARD,
|
||||
List.of("Let another player continue one fallen character.", "A valid cast spends the daily opportunity."),
|
||||
"Use the equipped shard while aiming at another player within 12 blocks.",
|
||||
Map.of(Stat.WIS, BigInteger.valueOf(30), Stat.CHR, BigInteger.valueOf(24), Stat.CON, BigInteger.valueOf(12)),
|
||||
BigInteger.ONE, AbilityDefinition.Activation.SELECTION, ControlInput.Gesture.USE, Duration.ofHours(24),
|
||||
Map.of("range", BigDecimal.valueOf(12), "selection-time", BigDecimal.valueOf(300),
|
||||
"death-age", BigDecimal.valueOf(86400)),
|
||||
AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) { return !closed && target(input) != null; }
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
|
||||
Player target = target(execution.input());
|
||||
if (target == null) {
|
||||
execution.input().player().sendMessage("Revive found no eligible player within 12 blocks and line of sight.");
|
||||
execution.finish(); return;
|
||||
}
|
||||
var captured = candidates(target);
|
||||
if (captured.isEmpty()) {
|
||||
execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive.");
|
||||
execution.finish(); return;
|
||||
}
|
||||
var loads = captured.stream().map(candidate -> characters.find(candidate.identity().characterId())).toList();
|
||||
CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); execution.input().player().sendMessage("Revive could not inspect those Tails safely."); execution.finish(); return; }
|
||||
if (closed || !execution.stillCurrent() || !target.isOnline()) { execution.finish(); return; }
|
||||
var eligible = new ArrayList<Choice>();
|
||||
Instant now = clock.instant();
|
||||
for (int index = 0; index < captured.size(); index++) {
|
||||
var found = loads.get(index).join();
|
||||
if (found.isEmpty()) continue;
|
||||
var record = found.orElseThrow();
|
||||
var candidate = captured.get(index);
|
||||
if (eligible(record, candidate.identity(), now) && unchanged(target, candidate)) eligible.add(new Choice(candidate, record));
|
||||
}
|
||||
if (eligible.isEmpty()) {
|
||||
execution.input().player().sendMessage("That player has no current Tail younger than 24 hours.");
|
||||
execution.finish(); return;
|
||||
}
|
||||
open(execution, target, eligible, now);
|
||||
}));
|
||||
}
|
||||
|
||||
private Player target(ControlInput input) {
|
||||
Player caster = input.player();
|
||||
Entity supplied = input.target();
|
||||
Player selected = supplied instanceof Player player ? player : null;
|
||||
if (selected == null) {
|
||||
Location eye = caster.getEyeLocation();
|
||||
var hit = caster.getWorld().rayTraceEntities(eye, eye.getDirection(), RANGE,
|
||||
entity -> entity instanceof Player && entity != caster);
|
||||
if (hit != null && hit.getHitEntity() instanceof Player player) selected = player;
|
||||
}
|
||||
if (selected == null || selected == caster || !selected.isOnline() || selected.getWorld() != caster.getWorld()
|
||||
|| selected.getLocation().distanceSquared(caster.getLocation()) > RANGE * RANGE
|
||||
|| !caster.hasLineOfSight(selected)) return null;
|
||||
return selected;
|
||||
}
|
||||
|
||||
private List<Candidate> candidates(Player target) {
|
||||
var result = new ArrayList<Candidate>();
|
||||
for (int slot = 0; slot <= 40; slot++) {
|
||||
if (slot > 35 && slot != 40) continue;
|
||||
ItemStack item = target.getInventory().getItem(slot);
|
||||
if (item == null || item.isEmpty()) continue;
|
||||
var identity = tails.identify(item);
|
||||
if (identity.isPresent()) result.add(new Candidate(slot, item.clone(), identity.orElseThrow()));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private boolean eligible(CharacterRecord record, TailBooks.Identity identity, Instant now) {
|
||||
if (!record.dead() || !record.id().equals(identity.characterId()) || !Objects.equals(record.deathId(), identity.deathId())) return false;
|
||||
Duration age = Duration.between(record.diedAt(), now);
|
||||
return !age.isNegative() && age.compareTo(DEATH_AGE) < 0;
|
||||
}
|
||||
|
||||
private boolean unchanged(Player target, Candidate candidate) {
|
||||
ItemStack current = target.getInventory().getItem(candidate.slot());
|
||||
return current != null && current.equals(candidate.captured());
|
||||
}
|
||||
|
||||
private void open(AbilityExecution execution, Player target, List<Choice> eligible, Instant now) {
|
||||
View old = targets.get(target.getUniqueId()); if (old != null) old.execution.finish();
|
||||
int size = Math.min(54, Math.max(9, ((eligible.size() + 8) / 9) * 9));
|
||||
Inventory inventory = popups.create(size, Component.text("Choose a Tail to Revive"));
|
||||
var choices = new LinkedHashMap<Integer, Choice>();
|
||||
for (int index = 0; index < eligible.size() && index < size; index++) {
|
||||
Choice choice = eligible.get(index); ItemStack display = choice.candidate().captured().clone(); display.setAmount(1);
|
||||
var meta = display.getItemMeta();
|
||||
long seconds = Math.max(0, Duration.between(now, choice.record().diedAt().plus(DEATH_AGE)).toSeconds());
|
||||
meta.lore(List.of(Component.text("Level " + choice.record().level()),
|
||||
Component.text("Eligible for " + seconds + " more seconds"), Component.text("Click to continue this character")));
|
||||
display.setItemMeta(meta); inventory.setItem(index, display); choices.put(index, choice);
|
||||
}
|
||||
var view = new View(execution, target, inventory, Map.copyOf(choices));
|
||||
inventories.put(inventory, view); targets.put(target.getUniqueId(), view); execution.own(view);
|
||||
target.openInventory(inventory);
|
||||
execution.later(VIEW_TIME, execution::finish);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void click(org.bukkit.event.inventory.InventoryClickEvent event) {
|
||||
var view = inventories.get(event.getView().getTopInventory());
|
||||
if (view == null) return;
|
||||
event.setCancelled(true);
|
||||
if (event.getWhoClicked() != view.target || event.getRawSlot() < 0 || event.getRawSlot() >= view.inventory.getSize()) return;
|
||||
Choice choice = view.choices.get(event.getRawSlot());
|
||||
if (choice == null || view.selecting) return;
|
||||
view.selecting = true; inventories.remove(view.inventory, view); targets.remove(view.target.getUniqueId(), view);
|
||||
view.inventory.clear(); view.target.closeInventory();
|
||||
confirm(view, choice);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void drag(org.bukkit.event.inventory.InventoryDragEvent event) {
|
||||
var view = inventories.get(event.getView().getTopInventory());
|
||||
if (view != null && event.getRawSlots().stream().anyMatch(slot -> slot < view.inventory.getSize())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void close(org.bukkit.event.inventory.InventoryCloseEvent event) {
|
||||
var view = inventories.get(event.getView().getTopInventory());
|
||||
if (view != null && !view.selecting) view.execution.finish();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
|
||||
var targetView = targets.get(event.getPlayer().getUniqueId()); if (targetView != null) targetView.execution.finish();
|
||||
for (var view : List.copyOf(inventories.values())) {
|
||||
if (view.execution.input().player() == event.getPlayer()) view.execution.finish();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void join(org.bukkit.event.player.PlayerJoinEvent event) { reconcile(event.getPlayer()); }
|
||||
|
||||
private void confirm(View view, Choice choice) {
|
||||
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || !unchanged(view.target, choice.candidate())) {
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
characters.find(choice.record().id()).whenComplete((found, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive could not recheck that Tail; no item changed."); view.execution.finish(); return; }
|
||||
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || found.isEmpty()
|
||||
|| !eligible(found.orElseThrow(), choice.candidate().identity(), clock.instant())
|
||||
|| !unchanged(view.target, choice.candidate())) {
|
||||
view.execution.input().player().sendMessage("That Tail is no longer eligible; no item changed.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
reserve(view, choice.candidate(), found.orElseThrow());
|
||||
}));
|
||||
}
|
||||
|
||||
private void reserve(View view, Candidate candidate, CharacterRecord dead) {
|
||||
ItemStack source = view.target.getInventory().getItem(candidate.slot());
|
||||
int destination = source.getAmount() == 1 ? -1 : emptyDirectSlot(view.target, candidate.slot());
|
||||
if (source.getAmount() > 1 && destination < 0) {
|
||||
view.target.sendMessage("Revive needs one empty direct inventory slot to preserve the copied Tails.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
var reservation = new RevivalStore.Reservation(UUID.randomUUID(), view.execution.character().id(),
|
||||
view.target.getUniqueId(), dead.id(), dead.deathId(), clock.instant().truncatedTo(ChronoUnit.SECONDS),
|
||||
candidate.slot(), source.getAmount(), destination);
|
||||
reservations.reserve(reservation).whenComplete((reserved, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; }
|
||||
if (reserved.isEmpty() || closed || !view.execution.stillCurrent() || !view.target.isOnline()
|
||||
|| !unchanged(view.target, candidate)) {
|
||||
view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
deliver(view, dead, reservation);
|
||||
}));
|
||||
}
|
||||
|
||||
private int emptyDirectSlot(Player target, int source) {
|
||||
for (int slot = 0; slot <= 40; slot++) {
|
||||
if (slot > 35 && slot != 40 || slot == source) continue;
|
||||
ItemStack item = target.getInventory().getItem(slot);
|
||||
if (item == null || item.isEmpty()) return slot;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void deliver(View view, CharacterRecord dead, RevivalStore.Reservation reservation) {
|
||||
try {
|
||||
var provisional = new CharacterRecord(dead.id(), dead.name(), dead.revision(), dead.stats(), dead.level(), dead.experience(),
|
||||
dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), dead.cooldowns(), dead.restrictedKills(),
|
||||
false, dead.deathId(), dead.diedAt());
|
||||
ItemStack sheet = sheets.create(provisional);
|
||||
ItemStack source = view.target.getInventory().getItem(reservation.sourceSlot());
|
||||
if (reservation.sourceAmount() == 1) view.target.getInventory().setItem(reservation.sourceSlot(), sheet);
|
||||
else {
|
||||
view.target.getInventory().setItem(reservation.destinationSlot(), sheet);
|
||||
var remaining = source.clone(); remaining.setAmount(reservation.sourceAmount() - 1);
|
||||
view.target.getInventory().setItem(reservation.sourceSlot(), remaining);
|
||||
}
|
||||
view.target.updateInventory();
|
||||
} catch (RuntimeException uncertain) {
|
||||
report(uncertain); view.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
|
||||
characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit)
|
||||
.whenComplete((restored, failure) -> schedule(() -> {
|
||||
if (failure != null || restored.isEmpty()) {
|
||||
if (failure != null) report(failure);
|
||||
view.target.sendMessage("The Tail changed, but character restoration is pending recovery; do not move the sheet.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
if (installFinalSheet(view.target, restored.orElseThrow())) {
|
||||
reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; });
|
||||
view.target.sendMessage("Restored character sheet: " + restored.orElseThrow().name() + ".");
|
||||
view.execution.input().player().sendMessage("Revive restored " + restored.orElseThrow().name() + ".");
|
||||
} else view.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");
|
||||
view.execution.finish();
|
||||
}));
|
||||
}
|
||||
|
||||
private boolean installFinalSheet(Player target, CharacterRecord record) {
|
||||
int slot = uniqueSheetSlot(target, record.id());
|
||||
if (slot < 0) return false;
|
||||
target.getInventory().setItem(slot, sheets.create(record)); target.updateInventory(); return true;
|
||||
}
|
||||
|
||||
private int uniqueSheetSlot(Player target, UUID character) {
|
||||
int found = -1;
|
||||
for (int slot = 0; slot <= 40; slot++) {
|
||||
if (slot > 35 && slot != 40) continue;
|
||||
if (sheets.identify(target.getInventory().getItem(slot)).filter(character::equals).isEmpty()) continue;
|
||||
if (found >= 0) return -2;
|
||||
found = slot;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public void reconcile(Player target) {
|
||||
if (closed || !target.isOnline()) return;
|
||||
reservations.target(target.getUniqueId()).whenComplete((found, failure) -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
for (var reservation : found) characters.find(reservation.character()).whenComplete((record, loadFailure) -> schedule(() -> {
|
||||
if (loadFailure != null) { report(loadFailure); return; }
|
||||
if (closed || !target.isOnline() || record.isEmpty()) return;
|
||||
reconcile(target, reservation, record.orElseThrow());
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private void reconcile(Player target, RevivalStore.Reservation reservation, CharacterRecord record) {
|
||||
int foundSheet = uniqueSheetSlot(target, reservation.character());
|
||||
boolean hasSheet = foundSheet >= 0;
|
||||
ItemStack source = target.getInventory().getItem(reservation.sourceSlot());
|
||||
boolean unchangedTail = source != null && source.getAmount() == reservation.sourceAmount()
|
||||
&& tails.identify(source).filter(id -> id.characterId().equals(reservation.character()) && id.deathId().equals(reservation.death())).isPresent();
|
||||
boolean consumedTail = reservation.sourceAmount() == 1 ? hasSheet
|
||||
: source != null && source.getAmount() == reservation.sourceAmount() - 1
|
||||
&& tails.identify(source).filter(id -> id.deathId().equals(reservation.death())).isPresent();
|
||||
if (!record.dead() && hasSheet && installFinalSheet(target, record)) {
|
||||
reservations.clear(reservation.character(), reservation.id()); return;
|
||||
}
|
||||
if (record.dead() && unchangedTail && !hasSheet) {
|
||||
reservations.clear(reservation.character(), reservation.id()); return;
|
||||
}
|
||||
if (record.dead() && hasSheet && (consumedTail || unchangedTail && reservation.sourceAmount() > 1)) {
|
||||
if (unchangedTail && reservation.sourceAmount() > 1) {
|
||||
var remaining = source.clone(); remaining.setAmount(source.getAmount() - 1);
|
||||
target.getInventory().setItem(reservation.sourceSlot(), remaining); target.updateInventory();
|
||||
}
|
||||
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
|
||||
characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit)
|
||||
.whenComplete((restored, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) {
|
||||
reservations.clear(reservation.character(), reservation.id());
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private void schedule(Runnable action) {
|
||||
try { nextTick.accept(action); } catch (RuntimeException rejected) { report(rejected); }
|
||||
}
|
||||
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
for (var view : List.copyOf(inventories.values())) view.execution.finish();
|
||||
inventories.clear(); targets.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
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.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
|
||||
/** Second Wind's validated persist-before-publication self-heal. */
|
||||
public final class SecondWind implements AbilityEffect {
|
||||
public record Settings(BigInteger baseHealing, Duration cooldown) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseHealing); Objects.requireNonNull(cooldown);
|
||||
if (baseHealing.signum() < 0 || baseHealing.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger()) > 0
|
||||
|| cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Second Wind settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(15)); }
|
||||
}
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<EntityRegainHealthEvent> healingEvents;
|
||||
private final Supplier<BigDecimal> nativeLimit;
|
||||
private volatile Settings settings;
|
||||
|
||||
public SecondWind(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
|
||||
Supplier<BigDecimal> nativeLimit) {
|
||||
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("second_wind", "Second Wind", Material.GHAST_TEAR,
|
||||
List.of("Restore missing fantasy health.",
|
||||
"Heals " + current.baseHealing() + " + WIS modifier, capped at maximum health."),
|
||||
"Use while injured; full health refuses without spending cooldown.",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-healing", new BigDecimal(current.baseHealing())),
|
||||
AbilityDefinition.TargetPolicy.SELF, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
double health = input.player().getHealth();
|
||||
var attribute = input.player().getAttribute(Attribute.MAX_HEALTH);
|
||||
return attribute != null && Double.isFinite(health) && health >= 0
|
||||
&& Double.isFinite(attribute.getValue()) && health < attribute.getValue();
|
||||
}
|
||||
|
||||
public static BigInteger healing(BigInteger wisdom) { return healing(BigInteger.valueOf(6), wisdom); }
|
||||
static BigInteger healing(BigInteger base, BigInteger wisdom) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(wisdom);
|
||||
if (base.signum() < 0 || wisdom.signum() < 0) throw new IllegalArgumentException("Invalid Second Wind healing input");
|
||||
BigInteger delta = wisdom.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 (!execution.stillCurrent()) return;
|
||||
var definition = execution.definition();
|
||||
BigInteger base = definition.parameters().get("base-healing").toBigIntegerExact();
|
||||
var player = execution.input().player();
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) { execution.finish(); return; }
|
||||
double current = player.getHealth(); double maximum = attribute.getValue();
|
||||
double missing = maximum - current;
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
|
||||
BigInteger exact = healing(base, execution.character().stats().get(Stat.WIS));
|
||||
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
|
||||
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
|
||||
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event);
|
||||
double accepted = Math.min(missing, event.getAmount());
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
|
||||
var record = execution.character(); var expected = record.revision();
|
||||
BigDecimal target = BigDecimal.valueOf(current + accepted);
|
||||
BigDecimal limit = nativeLimit.get();
|
||||
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty() || !execution.stillCurrent()) {
|
||||
player.sendMessage("Second Wind health could not be saved; no healing was applied."); return;
|
||||
}
|
||||
var persisted = saved.orElseThrow();
|
||||
if (!sessions.acknowledgeProgress(execution.input().session(), expected, persisted)) {
|
||||
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
|
||||
}
|
||||
double publish = Math.min(attribute.getValue(), Math.max(player.getHealth(), persisted.health().doubleValue()));
|
||||
player.setHealth(publish);
|
||||
} finally { execution.finish(); }
|
||||
});
|
||||
} catch (RuntimeException rejected) { execution.finish(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Stone Skin's captured, owned grant into the shared strongest-reduction stage. */
|
||||
public final class StoneSkin implements AbilityEffect, AutoCloseable {
|
||||
public record Settings(Duration cooldown, Duration duration, BigDecimal reductionPercent) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(reductionPercent);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|
||||
|| reductionPercent.signum() < 0 || reductionPercent.compareTo(BigDecimal.valueOf(100)) > 0)
|
||||
throw new IllegalArgumentException("Invalid Stone Skin settings");
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(Duration.ofSeconds(14), Duration.ofSeconds(4), BigDecimal.valueOf(25));
|
||||
}
|
||||
}
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final AbilityExecution execution; final Player player; final AutoCloseable grant; boolean closed;
|
||||
State(AbilityExecution execution, Player player, AutoCloseable grant) {
|
||||
this.execution = execution; this.player = player; this.grant = grant;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; protections.remove(player.getUniqueId(), this);
|
||||
try { grant.close(); } catch (Exception impossible) { throw new IllegalStateException(impossible); }
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final CombatMitigation mitigation;
|
||||
private final Map<UUID, State> protections = new java.util.HashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
public StoneSkin(Settings settings, CombatMitigation mitigation) {
|
||||
this.settings = Objects.requireNonNull(settings); this.mitigation = Objects.requireNonNull(mitigation);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("stone_skin", "Stone Skin", Material.BRICK,
|
||||
List.of("Reduce entity and projectile combat damage by " + decimal(current.reductionPercent()) + "%.",
|
||||
"Lasts " + decimal(BigDecimal.valueOf(current.duration().toMillis()).movePointLeft(3)) + " seconds without stacking."),
|
||||
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
|
||||
"damage-reduction-percent", current.reductionPercent()), AbilityDefinition.TargetPolicy.SELF, this);
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = from(execution.definition()); var player = execution.input().player();
|
||||
var grant = mitigation.grant(execution, player, current.reductionPercent());
|
||||
var state = new State(execution, player, grant);
|
||||
var previous = protections.put(player.getUniqueId(), state);
|
||||
if (previous != null) previous.execution.finish();
|
||||
execution.own(state); execution.later(current.duration(), execution::finish);
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
return new Settings(definition.cooldown(),
|
||||
Duration.ofNanos(definition.parameters().get("duration-seconds").movePointRight(9).longValueExact()),
|
||||
definition.parameters().get("damage-reduction-percent"));
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
for (var state : List.copyOf(protections.values())) state.execution.finish();
|
||||
protections.clear();
|
||||
}
|
||||
}
|
||||
@@ -16,30 +16,57 @@ import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BookMeta;
|
||||
|
||||
/** Authoritative inspection and validated party controls; character mutation actions remain unfinished. */
|
||||
/** Authoritative inspection, permanent-stat purchases and validated party controls. */
|
||||
public final class CharacterBookController implements Consumer<ControlInput>, AutoCloseable, org.bukkit.event.Listener {
|
||||
public record Failure(UUID player, UUID character, Throwable cause) { }
|
||||
private final CharacterStorage characters;
|
||||
private final ProgressionRules rules;
|
||||
private final games.dmg.fantasystick.session.EquipmentSessions sessions;
|
||||
private final PartyBookControls partyControls;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> failures;
|
||||
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 net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -51,6 +78,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
var owner = input.session().player();
|
||||
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) -> {
|
||||
@@ -96,6 +125,24 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
int start = Math.min(offset, (all.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
|
||||
int end = Math.min(start + VOLUME_SIZE, all.size());
|
||||
var pages = new java.util.ArrayList<Component>(all.subList(start, end));
|
||||
var statView = new StatView(UUID.randomUUID().toString(), input, record.revision());
|
||||
statViews.put(input.session().player(), statView);
|
||||
Component purchases = Component.text("Spend 1 point\n\n");
|
||||
for (var stat : Stat.values()) {
|
||||
purchases = purchases.append(Component.text("[+1 " + stat + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(STATS,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + statView.nonce() + ":" + stat + "\""))))
|
||||
.append(Component.newline());
|
||||
}
|
||||
pages.add(purchases);
|
||||
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);
|
||||
@@ -111,6 +158,52 @@ 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: " + durationSeconds(definition.cooldown()) + "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 ability\n\nSlot:\n");
|
||||
for (int slot = 0; slot < 7; slot++) {
|
||||
if (slot > 0) action = action.append(slot == 4 ? Component.newline() : Component.space());
|
||||
action = action.append(abilityButton(Integer.toString(slot + 3), ABILITY_ASSIGN, view, definition.id(), slot));
|
||||
}
|
||||
action = action.append(Component.text("\n\nRefund: "))
|
||||
.append(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id(), null));
|
||||
pages.add(action);
|
||||
}
|
||||
return java.util.List.copyOf(pages);
|
||||
}
|
||||
|
||||
private static String durationSeconds(java.time.Duration duration) {
|
||||
return java.math.BigDecimal.valueOf(duration.getSeconds())
|
||||
.add(java.math.BigDecimal.valueOf(duration.getNano(), 9)).stripTrailingZeros().toPlainString();
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -139,6 +232,113 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void statAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
if (closed || !STATS.equals(event.getIdentifier())
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
|
||||
var player = connection.getPlayer();
|
||||
var view = statViews.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player || event.getTag() == null) return;
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 80) return;
|
||||
var match = STAT_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))
|
||||
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
var stat = Stat.valueOf(match.group(2));
|
||||
var currentHealth = java.math.BigDecimal.valueOf(player.getHealth());
|
||||
var nativeLimit = NativeHealthLimit.read();
|
||||
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
|
||||
.whenComplete((purchase, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> finishPurchase(view, stat, purchase, failure));
|
||||
} catch (RuntimeException rejected) { report(view.input(), rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void finishPurchase(StatView view, Stat stat, CharacterStorage.StatPurchase purchase, Throwable failure) {
|
||||
var input = view.input();
|
||||
if (closed || !input.stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
report(input, failure);
|
||||
input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
|
||||
return;
|
||||
}
|
||||
if (purchase.status() == CharacterStorage.StatPurchaseStatus.APPLIED) {
|
||||
var saved = purchase.record().orElseThrow();
|
||||
try {
|
||||
if (!sessions.acknowledgeProgress(input.session(), view.revision(), saved)) {
|
||||
input.player().sendMessage("Stat purchase was saved; reopen the character after the session transition.");
|
||||
return;
|
||||
}
|
||||
} catch (RuntimeException nativeFailure) {
|
||||
report(input, nativeFailure);
|
||||
input.player().sendMessage("Stat purchase was saved, but native health refresh failed; exit for recovery.");
|
||||
return;
|
||||
}
|
||||
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
|
||||
load(input, 0);
|
||||
return;
|
||||
}
|
||||
String message = switch (purchase.status()) {
|
||||
case STALE -> "That character page is stale; reopen it before spending points.";
|
||||
case NO_POINTS -> "This character has no unspent points.";
|
||||
case UNAVAILABLE -> "This character is no longer available.";
|
||||
case APPLIED -> throw new IllegalStateException("Handled applied purchase");
|
||||
};
|
||||
input.player().sendMessage(message);
|
||||
load(input, 0);
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void abilityAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
boolean unlock = ABILITY_UNLOCK.equals(event.getIdentifier());
|
||||
boolean assign = ABILITY_ASSIGN.equals(event.getIdentifier());
|
||||
boolean unlearn = ABILITY_UNLEARN.equals(event.getIdentifier());
|
||||
if (closed || abilityEngine == null || !unlock && !assign && !unlearn
|
||||
|| !(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))
|
||||
|| assign != (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())
|
||||
: unlearn ? abilityEngine.unlearn(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, unlearn, ability, mutation.status()));
|
||||
load(view.input(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
private static String abilityMessage(boolean unlock, boolean unlearn, String ability,
|
||||
CharacterStorage.AbilityMutationStatus status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> unlock ? "Learned " + ability + "."
|
||||
: unlearn ? "Unlearned " + ability + " and refunded 1 point." : "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 -> unlearn ? "This character no longer knows " + ability + "."
|
||||
: "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); }
|
||||
|
||||
@@ -147,8 +347,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
var owner = event.getPlayer().getUniqueId();
|
||||
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(); partyControls.close(); }
|
||||
@Override public void close() {
|
||||
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); partyControls.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package games.dmg.fantasystick.combat;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
|
||||
/** Server-thread built-in attack consumer; the bound stick's vanilla attack is always contained upstream. */
|
||||
public final class BasicAttack implements Consumer<ControlInput>, Listener, AutoCloseable {
|
||||
private static final Duration CADENCE = Duration.ofSeconds(1);
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock;
|
||||
private final Map<UUID, Instant> readyAt = new HashMap<>();
|
||||
private Pending pending;
|
||||
private boolean closed;
|
||||
|
||||
private static final class Pending {
|
||||
final ControlInput input;
|
||||
final LivingEntity target;
|
||||
final Instant attackedAt;
|
||||
boolean accepted;
|
||||
Pending(ControlInput input, LivingEntity target, Instant attackedAt) {
|
||||
this.input = input; this.target = target; this.attackedAt = attackedAt;
|
||||
}
|
||||
}
|
||||
|
||||
public BasicAttack(EquipmentSessions sessions) {
|
||||
this(sessions, Clock.systemUTC());
|
||||
}
|
||||
|
||||
public BasicAttack(EquipmentSessions sessions, Clock clock) {
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) {
|
||||
if (closed || pending != null || input.kind() != ControlInput.Kind.BASIC_ATTACK
|
||||
|| input.gesture() != ControlInput.Gesture.ATTACK
|
||||
|| !(input.target() instanceof LivingEntity target) || !input.stillCurrent()) return;
|
||||
var record = sessions.currentRecord(input.session()).orElse(null);
|
||||
if (record == null) return;
|
||||
Instant now = clock.instant();
|
||||
Instant ready = readyAt.get(input.session().character());
|
||||
if (ready != null && now.isBefore(ready)) return;
|
||||
BigInteger damage = damage(record.stats().get(Stat.STR));
|
||||
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
|
||||
var attempt = new Pending(input, target, now);
|
||||
pending = attempt;
|
||||
try {
|
||||
target.damage(nativeDamage, input.player());
|
||||
} finally {
|
||||
if (pending == attempt) pending = null;
|
||||
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
|
||||
}
|
||||
}
|
||||
|
||||
static BigInteger damage(BigInteger strength) {
|
||||
Objects.requireNonNull(strength);
|
||||
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
|
||||
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).max(BigInteger.ONE);
|
||||
}
|
||||
|
||||
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = pending;
|
||||
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|
||||
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
|
||||
attempt.accepted = true;
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
pending = null;
|
||||
readyAt.clear();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.CombatContributions;
|
||||
import games.dmg.fantasystick.progression.CombatRewardRules;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import games.dmg.fantasystick.session.SessionVisuals;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.EntityRemoveEvent;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
|
||||
/** Accepted native contribution capture and durable character-bound XP delivery. */
|
||||
public final class CombatExperience implements Listener, AutoCloseable {
|
||||
public record Failure(UUID character, Throwable cause) { }
|
||||
private record Defeat(EquipmentSessions.Active active, int level) { }
|
||||
private static final Set<EntityType> PASSIVE = Set.of(
|
||||
EntityType.ALLAY, EntityType.ARMADILLO, EntityType.AXOLOTL, EntityType.BAT, EntityType.CAMEL,
|
||||
EntityType.CAT, EntityType.CHICKEN, EntityType.COD, EntityType.COPPER_GOLEM, EntityType.COW,
|
||||
EntityType.DONKEY, EntityType.FOX, EntityType.FROG, EntityType.GLOW_SQUID, EntityType.HAPPY_GHAST,
|
||||
EntityType.HORSE, EntityType.MOOSHROOM, EntityType.MULE, EntityType.NAUTILUS, EntityType.OCELOT,
|
||||
EntityType.PARROT, EntityType.PIG, EntityType.RABBIT, EntityType.SALMON, EntityType.SHEEP,
|
||||
EntityType.SKELETON_HORSE, EntityType.SNIFFER, EntityType.SNOW_GOLEM, EntityType.SQUID,
|
||||
EntityType.STRIDER, EntityType.TADPOLE, EntityType.TROPICAL_FISH, EntityType.TURTLE,
|
||||
EntityType.VILLAGER, EntityType.WANDERING_TRADER, EntityType.ZOMBIE_HORSE);
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final ProgressionRules progression;
|
||||
private final CombatRewardRules rewards;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Function<UUID, Player> players;
|
||||
private final Consumer<Failure> failures;
|
||||
private final CombatSources sources;
|
||||
private final CombatContributions contributions;
|
||||
private final Map<PlayerDeathEvent, Defeat> defeats = new IdentityHashMap<>();
|
||||
private final Map<UUID, PendingAward> pending = new ConcurrentHashMap<>();
|
||||
private final java.util.concurrent.ConcurrentLinkedQueue<UUID> retries = new java.util.concurrent.ConcurrentLinkedQueue<>();
|
||||
private org.bukkit.scheduler.BukkitTask retryTask;
|
||||
private boolean closed;
|
||||
|
||||
private final class PendingAward {
|
||||
final UUID id = UUID.randomUUID();
|
||||
final UUID character;
|
||||
final BigInteger reward;
|
||||
final CombatRewardRules.KillPosition restricted;
|
||||
BigInteger expectedRevision;
|
||||
boolean running;
|
||||
PendingAward(UUID character, BigInteger reward, CombatRewardRules.KillPosition restricted) {
|
||||
this.character = character; this.reward = reward; this.restricted = restricted;
|
||||
}
|
||||
}
|
||||
|
||||
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
|
||||
EquipmentSessions sessions, Clock clock, Consumer<Runnable> nextTick,
|
||||
Function<UUID, Player> players, Consumer<Failure> failures) {
|
||||
this.characters = Objects.requireNonNull(characters); this.progression = Objects.requireNonNull(progression);
|
||||
this.rewards = Objects.requireNonNull(rewards); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.players = Objects.requireNonNull(players); this.failures = Objects.requireNonNull(failures);
|
||||
sources = new CombatSources(sessions); contributions = new CombatContributions(rewards);
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
if (closed || retryTask != null) throw new IllegalStateException("Combat experience is already installed or closed");
|
||||
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::retryPending, 20, 20);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (closed || event.getFinalDamage() <= 0) return;
|
||||
var origin = source(event);
|
||||
if (origin == null || origin.session() == null || !sources.current(origin)
|
||||
|| event.getEntity().getUniqueId().equals(origin.player())) return;
|
||||
contributions.accept(event.getEntity().getUniqueId(), origin.character(), clock.instant());
|
||||
}
|
||||
|
||||
private CombatSources.Origin source(EntityDamageEvent event) {
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
if (ongoing != null) return ongoing.source();
|
||||
}
|
||||
var origin = sources.read(event.getDamageSource().getDirectEntity());
|
||||
if (origin == null && event instanceof EntityDamageByEntityEvent byEntity) origin = sources.read(byEntity.getDamager());
|
||||
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
|
||||
return origin;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
||||
public void beforePlayerDeath(PlayerDeathEvent event) {
|
||||
if (closed) return;
|
||||
var active = sessions.active(event.getEntity().getUniqueId()).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
if (record != null) defeats.put(event, new Defeat(active, record.level()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void playerDeath(PlayerDeathEvent event) {
|
||||
var defeat = defeats.remove(event);
|
||||
if (closed || defeat == null || !event.isCancelled() || sessions.current(defeat.active())) return;
|
||||
award(event.getEntity(), CombatRewardRules.Target.FANTASY_CHARACTER, defeat.level(), defeat.active().character());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void mobDeath(EntityDeathEvent event) {
|
||||
if (closed || event instanceof PlayerDeathEvent) return;
|
||||
award(event.getEntity(), classify(event.getEntity()), 0, null);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void removed(EntityRemoveEvent event) {
|
||||
if (!closed && event.getCause() != EntityRemoveEvent.Cause.DEATH) {
|
||||
contributions.invalidate(event.getEntity().getUniqueId());
|
||||
}
|
||||
}
|
||||
|
||||
private void award(LivingEntity target, CombatRewardRules.Target kind, int level, UUID defeatedCharacter) {
|
||||
Instant now = clock.instant();
|
||||
var contributors = new ArrayList<>(contributions.consume(target.getUniqueId(), now));
|
||||
if (defeatedCharacter != null) contributors.remove(defeatedCharacter);
|
||||
if (contributors.isEmpty()) return;
|
||||
BigInteger share = rewards.share(rewards.pool(kind, level), contributors.size());
|
||||
CombatRewardRules.KillPosition restricted = kind == CombatRewardRules.Target.PASSIVE
|
||||
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
|
||||
for (UUID character : contributors) {
|
||||
var operation = new PendingAward(character, share, restricted);
|
||||
pending.put(operation.id, operation);
|
||||
attempt(operation);
|
||||
}
|
||||
}
|
||||
|
||||
private static CombatRewardRules.KillPosition position(Location location) {
|
||||
var world = Objects.requireNonNull(location.getWorld(), "Kill location has no world").getUID();
|
||||
return new CombatRewardRules.KillPosition(Objects.requireNonNull(world, "Kill world has no UUID"),
|
||||
BigDecimal.valueOf(location.getX()), BigDecimal.valueOf(location.getY()), BigDecimal.valueOf(location.getZ()));
|
||||
}
|
||||
|
||||
private void attempt(PendingAward operation) {
|
||||
synchronized (operation) {
|
||||
if (closed || operation.running || !pending.containsKey(operation.id)) return;
|
||||
operation.running = true;
|
||||
}
|
||||
if (operation.expectedRevision == null) {
|
||||
characters.find(operation.character).whenComplete((record, failure) -> {
|
||||
synchronized (operation) { operation.running = false; }
|
||||
if (failure != null) { fail(operation, failure); return; }
|
||||
if (record.isEmpty() || record.orElseThrow().dead()) { pending.remove(operation.id); return; }
|
||||
operation.expectedRevision = record.orElseThrow().revision();
|
||||
attempt(operation);
|
||||
});
|
||||
return;
|
||||
}
|
||||
characters.awardCombatExperience(operation.character, operation.expectedRevision, operation.reward,
|
||||
progression, rewards, operation.restricted).whenComplete((award, failure) -> {
|
||||
synchronized (operation) { operation.running = false; }
|
||||
if (failure != null) { fail(operation, failure); return; }
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
|
||||
operation.expectedRevision = award.record().orElseThrow().revision(); attempt(operation); return;
|
||||
}
|
||||
pending.remove(operation.id);
|
||||
if (award.record().isEmpty() || award.status() == CharacterStorage.CombatAwardStatus.UNAVAILABLE) return;
|
||||
try { nextTick.accept(() -> acknowledge(operation, award)); }
|
||||
catch (RuntimeException rejected) { report(operation.character, rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void fail(PendingAward operation, Throwable failure) {
|
||||
report(operation.character, failure);
|
||||
if (!closed && pending.containsKey(operation.id)) retries.offer(operation.id);
|
||||
}
|
||||
|
||||
public void retryPending() {
|
||||
if (closed) return;
|
||||
for (int attempted = 0; attempted < 64; attempted++) {
|
||||
UUID id = retries.poll();
|
||||
if (id == null) break;
|
||||
var operation = pending.get(id);
|
||||
if (operation != null) attempt(operation);
|
||||
}
|
||||
}
|
||||
|
||||
private void acknowledge(PendingAward operation, CharacterStorage.CombatAward award) {
|
||||
if (closed) return;
|
||||
CharacterRecord saved = award.record().orElseThrow();
|
||||
var active = sessions.activeCharacter(operation.character).orElse(null);
|
||||
if (active != null && !sessions.acknowledgeProgress(active, saved.revision().subtract(BigInteger.ONE), saved)
|
||||
&& !sessions.currentRecord(active).filter(saved::equals).isPresent()) {
|
||||
report(operation.character, new IllegalStateException("Saved combat progress could not synchronize with active session"));
|
||||
return;
|
||||
}
|
||||
Player player = active == null ? null : players.apply(active.player());
|
||||
if (player == null || !player.isOnline()) return;
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.APPLIED) {
|
||||
player.sendMessage("Earned " + operation.reward + " fantasy XP.");
|
||||
for (int gained = 1; gained <= award.levels(); gained++) {
|
||||
int level = saved.level() - award.levels() + gained;
|
||||
player.sendMessage("Level " + level + "! Gained 1 character point.");
|
||||
SessionVisuals.burst(player, Color.BLUE, 12);
|
||||
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
|
||||
}
|
||||
} else if (award.status() == CharacterStorage.CombatAwardStatus.SPACING_BLOCKED) {
|
||||
player.sendMessage("No fantasy XP: this restricted kill was too close to your previous one in this world.");
|
||||
} else if (award.status() == CharacterStorage.CombatAwardStatus.AT_CAP) {
|
||||
player.sendMessage("No fantasy XP: this character is already at the configured maximum level.");
|
||||
} else if (award.status() == CharacterStorage.CombatAwardStatus.UNCHANGED) {
|
||||
player.sendMessage("No fantasy XP: this target's configured reward is zero.");
|
||||
}
|
||||
}
|
||||
|
||||
static CombatRewardRules.Target classify(LivingEntity entity) {
|
||||
EntityType type = entity.getType();
|
||||
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON) return CombatRewardRules.Target.BOSS;
|
||||
return PASSIVE.contains(type) ? CombatRewardRules.Target.PASSIVE : CombatRewardRules.Target.ORDINARY;
|
||||
}
|
||||
|
||||
private void report(UUID character, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, failure)); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics never replay or acknowledge a reward. */ }
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
if (retryTask != null) retryTask.cancel();
|
||||
defeats.clear(); pending.clear(); retries.clear(); contributions.close();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.progression.CombatRewardRules;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
@@ -16,7 +17,16 @@ public record CharacterRecord(
|
||||
UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout,
|
||||
cooldowns, Map.of(), dead, deathId, diedAt);
|
||||
}
|
||||
|
||||
public CharacterRecord {
|
||||
Objects.requireNonNull(id);
|
||||
Objects.requireNonNull(name);
|
||||
@@ -28,6 +38,7 @@ public record CharacterRecord(
|
||||
learned = Set.copyOf(learned);
|
||||
loadout = List.copyOf(loadout);
|
||||
cooldowns = Map.copyOf(cooldowns);
|
||||
restrictedKills = Map.copyOf(restrictedKills);
|
||||
if (revision.signum() < 0 || experience.signum() < 0 || unspentPoints.signum() < 0 || level < 1
|
||||
|| !stats.keySet().equals(EnumSet.allOf(Stat.class))
|
||||
|| stats.values().stream().anyMatch(value -> value.signum() < 0)
|
||||
@@ -35,6 +46,8 @@ public record CharacterRecord(
|
||||
|| health.compareTo(new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN))) > 0
|
||||
|| loadout.size() != 7 || learned.stream().anyMatch(String::isBlank)
|
||||
|| cooldowns.keySet().stream().anyMatch(String::isBlank)
|
||||
|| restrictedKills.size() > 1024
|
||||
|| restrictedKills.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(entry.getValue().world()))
|
||||
|| (deathId == null) != (diedAt == null) || dead && deathId == null) {
|
||||
throw new IllegalArgumentException("Invalid character record");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
@@ -204,26 +206,259 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (old.name().equals(name)) return Optional.of(old);
|
||||
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(),
|
||||
false, old.deathId(), old.diedAt());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(renamed, old.revision());
|
||||
return Optional.of(renamed);
|
||||
});
|
||||
}
|
||||
|
||||
public enum ExperienceAwardStatus { APPLIED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
|
||||
public record ExperienceAward(ExperienceAwardStatus status, Optional<CharacterRecord> record, int levels) { }
|
||||
|
||||
/** Persist configured transferable XP/level/point progression as one expected revision. */
|
||||
public CompletableFuture<ExperienceAward> awardExperience(UUID characterId, BigInteger expectedRevision,
|
||||
BigInteger reward, ProgressionRules rules) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(expectedRevision);
|
||||
Objects.requireNonNull(reward);
|
||||
Objects.requireNonNull(rules);
|
||||
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new ExperienceAward(ExperienceAwardStatus.UNAVAILABLE, Optional.empty(), 0);
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) {
|
||||
return new ExperienceAward(ExperienceAwardStatus.STALE, Optional.of(old), 0);
|
||||
}
|
||||
var cap = rules.xpPerLevel().multiply(BigInteger.valueOf(rules.maximumLevel() - 1L));
|
||||
if (old.level() >= rules.maximumLevel() || old.experience().compareTo(cap) >= 0) {
|
||||
return new ExperienceAward(ExperienceAwardStatus.AT_CAP, Optional.of(old), 0);
|
||||
}
|
||||
var experience = old.experience().add(reward).min(cap);
|
||||
if (experience.equals(old.experience())) {
|
||||
var status = old.level() >= rules.maximumLevel()
|
||||
? ExperienceAwardStatus.AT_CAP : ExperienceAwardStatus.UNCHANGED;
|
||||
return new ExperienceAward(status, Optional.of(old), 0);
|
||||
}
|
||||
int level = experience.divide(rules.xpPerLevel()).intValueExact() + 1;
|
||||
int gained = Math.max(0, level - old.level());
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
|
||||
});
|
||||
}
|
||||
|
||||
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
|
||||
public record CombatAward(CombatAwardStatus status, Optional<CharacterRecord> record, int levels) { }
|
||||
|
||||
/** Persist one combat share and its restricted-kill reference in the same character revision. */
|
||||
public CompletableFuture<CombatAward> awardCombatExperience(UUID characterId, BigInteger expectedRevision,
|
||||
BigInteger reward, ProgressionRules progression,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules rewards,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules.KillPosition restrictedKill) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision);
|
||||
Objects.requireNonNull(reward); Objects.requireNonNull(progression); Objects.requireNonNull(rewards);
|
||||
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new CombatAward(CombatAwardStatus.UNAVAILABLE, Optional.empty(), 0);
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) {
|
||||
return new CombatAward(CombatAwardStatus.STALE, Optional.of(old), 0);
|
||||
}
|
||||
boolean eligible = restrictedKill == null || old.restrictedKills().get(restrictedKill.world()) == null
|
||||
|| rewards.spaced(old.restrictedKills().get(restrictedKill.world()), restrictedKill);
|
||||
var history = new java.util.HashMap<>(old.restrictedKills());
|
||||
if (restrictedKill != null) history.put(restrictedKill.world(), restrictedKill);
|
||||
var cap = progression.xpPerLevel().multiply(BigInteger.valueOf(progression.maximumLevel() - 1L));
|
||||
boolean atCap = old.level() >= progression.maximumLevel() || old.experience().compareTo(cap) >= 0;
|
||||
var experience = eligible && !atCap ? old.experience().add(reward).min(cap) : old.experience();
|
||||
int level = atCap ? old.level() : experience.divide(progression.xpPerLevel()).intValueExact() + 1;
|
||||
int gained = Math.max(0, level - old.level());
|
||||
var status = !eligible ? CombatAwardStatus.SPACING_BLOCKED : atCap ? CombatAwardStatus.AT_CAP
|
||||
: experience.equals(old.experience()) ? CombatAwardStatus.UNCHANGED : CombatAwardStatus.APPLIED;
|
||||
if (history.equals(old.restrictedKills()) && experience.equals(old.experience())) {
|
||||
return new CombatAward(status, Optional.of(old), 0);
|
||||
}
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), history, false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new CombatAward(status, Optional.of(saved), gained);
|
||||
});
|
||||
}
|
||||
|
||||
public enum 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));
|
||||
});
|
||||
}
|
||||
|
||||
/** Refund one learned ability exactly once, clear every equipped reference and retain its deadline. */
|
||||
public CompletableFuture<AbilityMutation> unlearnAbility(UUID characterId, BigInteger expectedRevision, String ability) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
|
||||
if (ability.isBlank()) throw new IllegalArgumentException("Invalid ability refund");
|
||||
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 learned = new java.util.HashSet<>(old.learned()); learned.remove(ability);
|
||||
var loadout = old.loadout().stream().map(assigned -> assigned.equals(ability) ? "" : assigned).toList();
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, 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) { }
|
||||
|
||||
/** Persist one active-session stat purchase and its current native health as one revision. */
|
||||
public CompletableFuture<StatPurchase> purchaseStat(UUID characterId, BigInteger expectedRevision, Stat stat,
|
||||
java.math.BigDecimal currentHealth,
|
||||
java.math.BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(expectedRevision);
|
||||
Objects.requireNonNull(stat);
|
||||
Objects.requireNonNull(currentHealth);
|
||||
Objects.requireNonNull(nativeLimit);
|
||||
if (currentHealth.signum() < 0 || nativeLimit.signum() <= 0) {
|
||||
throw new IllegalArgumentException("Invalid stat-purchase health bound");
|
||||
}
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new StatPurchase(StatPurchaseStatus.UNAVAILABLE, Optional.empty());
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) {
|
||||
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
|
||||
}
|
||||
if (old.unspentPoints().signum() == 0) {
|
||||
return new StatPurchase(StatPurchaseStatus.NO_POINTS, Optional.of(old));
|
||||
}
|
||||
var oldMaximum = old.maximumHealth().min(nativeLimit);
|
||||
if (currentHealth.compareTo(oldMaximum) > 0) {
|
||||
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
|
||||
}
|
||||
var stats = new java.util.EnumMap<Stat, BigInteger>(Stat.class);
|
||||
stats.putAll(old.stats());
|
||||
stats.put(stat, stats.get(stat).add(BigInteger.ONE));
|
||||
var rawMaximum = new java.math.BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN));
|
||||
var gain = rawMaximum.min(nativeLimit).subtract(oldMaximum);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
|
||||
currentHealth.add(gain), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false,
|
||||
old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
/** Session-owned remaining health; retain all other current fields and reject concurrent revision changes. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, java.math.BigDecimal health,
|
||||
java.math.BigDecimal nativeLimit) {
|
||||
return saveHealth(characterId, null, health, nativeLimit);
|
||||
}
|
||||
|
||||
/** Ability-owned health mutation that refuses a changed character revision rather than healing stale state. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, BigInteger expectedRevision,
|
||||
java.math.BigDecimal health, java.math.BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(characterId);
|
||||
if (health.signum() < 0 || nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid health bound");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
|
||||
var old = found.orElseThrow();
|
||||
if (expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
|
||||
var bounded = health.min(old.maximumHealth()).min(nativeLimit);
|
||||
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.cooldowns(),
|
||||
false, old.deathId(), old.diedAt());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return Optional.of(saved);
|
||||
});
|
||||
@@ -254,15 +489,22 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (old.dead() || expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
|
||||
var dead = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.ZERO, old.learned(),
|
||||
old.loadout(), old.cooldowns(), true, deathId, diedAt);
|
||||
old.loadout(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt);
|
||||
repository.replace(dead, old.revision());
|
||||
return Optional.of(dead);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecord(UUID characterId, UUID deathId) {
|
||||
return restoreDeathRecord(characterId, deathId, null);
|
||||
}
|
||||
|
||||
/** Native Revive supplies the current plugin-controlled health ceiling captured on the server thread. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecord(UUID characterId, UUID deathId,
|
||||
java.math.BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(deathId);
|
||||
if (nativeLimit != null && nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid revival health limit");
|
||||
Instant selectedAt = clock.instant();
|
||||
return submit(() -> {
|
||||
Optional<CharacterRecord> found = repository.load(characterId);
|
||||
@@ -272,9 +514,10 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (!deathId.equals(old.deathId()) || age.isNegative() || age.compareTo(Duration.ofHours(24)) >= 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
var health = nativeLimit == null ? old.maximumHealth() : old.maximumHealth().min(nativeLimit);
|
||||
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), old.maximumHealth(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), false, old.deathId(), old.diedAt());
|
||||
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(restored, old.revision());
|
||||
return Optional.of(restored);
|
||||
});
|
||||
|
||||
@@ -46,6 +46,13 @@ final class CharacterYaml {
|
||||
record.cooldowns().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry ->
|
||||
cooldowns.add(Map.of("ability", entry.getKey(), "ready-at", entry.getValue().toString())));
|
||||
root.put("cooldowns", cooldowns);
|
||||
List<Map<String, String>> restrictedKills = new ArrayList<>();
|
||||
record.restrictedKills().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
|
||||
var position = entry.getValue();
|
||||
restrictedKills.add(Map.of("world", entry.getKey().toString(), "x", position.x().toString(),
|
||||
"y", position.y().toString(), "z", position.z().toString()));
|
||||
});
|
||||
root.put("restricted-kills", restrictedKills);
|
||||
root.put("dead", record.dead());
|
||||
if (record.deathId() != null) {
|
||||
root.put("death-id", record.deathId().toString());
|
||||
@@ -74,11 +81,19 @@ final class CharacterYaml {
|
||||
throw new IllegalArgumentException("Duplicate cooldown");
|
||||
}
|
||||
}
|
||||
Map<UUID, games.dmg.fantasystick.progression.CombatRewardRules.KillPosition> restrictedKills = new LinkedHashMap<>();
|
||||
for (Object value : root.containsKey("restricted-kills") ? list(root.get("restricted-kills")) : List.of()) {
|
||||
Map<?, ?> row = object(value);
|
||||
UUID world = UUID.fromString(text(row.get("world")));
|
||||
var position = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(world,
|
||||
new BigDecimal(text(row.get("x"))), new BigDecimal(text(row.get("y"))), new BigDecimal(text(row.get("z"))));
|
||||
if (restrictedKills.putIfAbsent(world, position) != null) throw new IllegalArgumentException("Duplicate restricted-kill world");
|
||||
}
|
||||
if (!(root.get("dead") instanceof Boolean dead)) throw new IllegalArgumentException("Missing dead flag");
|
||||
return new CharacterRecord(UUID.fromString(text(root.get("character-id"))), text(root.get("name")),
|
||||
number(root.get("revision")), stats, number(root.get("level")).intValueExact(),
|
||||
number(root.get("experience")), number(root.get("unspent-points")),
|
||||
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")), cooldowns,
|
||||
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")), cooldowns, restrictedKills,
|
||||
dead, root.containsKey("death-id") ? UUID.fromString(text(root.get("death-id"))) : null,
|
||||
root.containsKey("died-at") ? instant(root.get("died-at")) : null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.FrostShard;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Serialized durable Frost Shard settings; publication remains a server-thread caller decision. */
|
||||
public final class FrostShardSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_DAMAGE = "frost-shard-base-damage";
|
||||
public static final String COOLDOWN = "frost-shard-cooldown-seconds";
|
||||
public static final String RANGE = "frost-shard-range-blocks";
|
||||
public static final String LIFETIME = "frost-shard-lifetime-seconds";
|
||||
public static final String SPEED = "frost-shard-projectile-speed-blocks-per-tick";
|
||||
public static final String AMPLIFIER = "frost-shard-slowness-amplifier";
|
||||
public static final String SLOWNESS = "frost-shard-slowness-duration-seconds";
|
||||
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, LIFETIME, SPEED, RANGE, AMPLIFIER, SLOWNESS);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "5", RANGE, "32",
|
||||
LIFETIME, "5", SPEED, "1", AMPLIFIER, "0", SLOWNESS, "2");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, FrostShard.Settings settings,
|
||||
Map<String, String> values, long sequence) {
|
||||
public Update { values = Map.copyOf(values); }
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-frost-shard-configuration"));
|
||||
private volatile Map<String, String> effectiveValues;
|
||||
private Map<String, String> persistedValues;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public FrostShardSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
FrostShardSettings(Path directory, Map<String, String> initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
var copy = new LinkedHashMap<String, String>();
|
||||
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
|
||||
parse(copy);
|
||||
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
|
||||
}
|
||||
|
||||
public static List<String> keys() { return KEYS; }
|
||||
public static String defaultValue(String key) { return DEFAULTS.get(key); }
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public FrostShard.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
Objects.requireNonNull(key); Objects.requireNonNull(value);
|
||||
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
|
||||
String accepted = canonical(key, value);
|
||||
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
|
||||
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
var next = new LinkedHashMap<>(persistedValues);
|
||||
next.put(key, accepted);
|
||||
var parsed = parse(next);
|
||||
save(key, accepted);
|
||||
persistedValues = Map.copyOf(next);
|
||||
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Frost Shard configuration save refused or failed; effective settings unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String key, String raw) {
|
||||
try {
|
||||
if (key.equals(BASE_DAMAGE) || key.equals(AMPLIFIER)) return new java.math.BigInteger(raw).toString();
|
||||
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static FrostShard.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
double range = finite(values.get(RANGE));
|
||||
var lifetime = seconds(values.get(LIFETIME));
|
||||
double speed = finite(values.get(SPEED));
|
||||
int amplifier = new java.math.BigInteger(values.get(AMPLIFIER)).intValueExact();
|
||||
var slowness = seconds(values.get(SLOWNESS));
|
||||
return new FrostShard.Settings(base, cooldown, range, lifetime, speed, amplifier, slowness);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Frost Shard settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new java.math.BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void save(String key, String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml");
|
||||
var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options));
|
||||
Map<String, Object> document = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
|
||||
document.put(savedKey, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(key, value);
|
||||
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() {
|
||||
pending--;
|
||||
if (closed && pending == 0) drained.complete(null);
|
||||
}
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() {
|
||||
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Longbow;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Serialized durable Longbow settings; publication remains a server-thread caller decision. */
|
||||
public final class LongbowSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_DAMAGE = "longbow-base-damage";
|
||||
public static final String RECOVERY = "longbow-recovery-seconds";
|
||||
public static final String MINIMUM_DRAW = "longbow-minimum-draw-ticks";
|
||||
public static final String FULL_DRAW = "longbow-full-draw-ticks";
|
||||
public static final String RANGE = "longbow-range-blocks";
|
||||
public static final String LIFETIME = "longbow-lifetime-seconds";
|
||||
public static final String FULL_SPEED = "longbow-full-projectile-speed-blocks-per-tick";
|
||||
private static final List<String> KEYS = List.of(BASE_DAMAGE, RECOVERY, MINIMUM_DRAW, FULL_DRAW, RANGE,
|
||||
LIFETIME, FULL_SPEED);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "5", RECOVERY, "1",
|
||||
MINIMUM_DRAW, "3", FULL_DRAW, "20", RANGE, "64", LIFETIME, "10", FULL_SPEED, "3");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Longbow.Settings settings,
|
||||
Map<String, String> values, long sequence) {
|
||||
public Update { values = Map.copyOf(values); }
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-longbow-configuration"));
|
||||
private volatile Map<String, String> effectiveValues;
|
||||
private Map<String, String> persistedValues;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public LongbowSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
LongbowSettings(Path directory, Map<String, String> initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
var copy = new LinkedHashMap<String, String>();
|
||||
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
|
||||
parse(copy);
|
||||
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
|
||||
}
|
||||
|
||||
public static List<String> keys() { return KEYS; }
|
||||
public static String defaultValue(String key) { return DEFAULTS.get(key); }
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public Longbow.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
Objects.requireNonNull(key); Objects.requireNonNull(value);
|
||||
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
|
||||
String accepted = canonical(key, value);
|
||||
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
|
||||
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
var next = new LinkedHashMap<>(persistedValues);
|
||||
next.put(key, accepted);
|
||||
var parsed = parse(next);
|
||||
save(key, accepted);
|
||||
persistedValues = Map.copyOf(next);
|
||||
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Longbow configuration save refused or failed; effective settings unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String key, String raw) {
|
||||
try {
|
||||
if (key.equals(BASE_DAMAGE) || key.equals(MINIMUM_DRAW) || key.equals(FULL_DRAW)) {
|
||||
return new java.math.BigInteger(raw).toString();
|
||||
}
|
||||
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static Longbow.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
|
||||
var recovery = seconds(values.get(RECOVERY));
|
||||
int minimumDraw = new java.math.BigInteger(values.get(MINIMUM_DRAW)).intValueExact();
|
||||
int fullDraw = new java.math.BigInteger(values.get(FULL_DRAW)).intValueExact();
|
||||
double range = finite(values.get(RANGE));
|
||||
var lifetime = seconds(values.get(LIFETIME));
|
||||
double speed = finite(values.get(FULL_SPEED));
|
||||
return new Longbow.Settings(base, recovery, minimumDraw, fullDraw, range, lifetime, speed);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Longbow settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new java.math.BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void save(String key, String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml");
|
||||
var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options));
|
||||
Map<String, Object> document = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
|
||||
document.put(savedKey, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(key, value);
|
||||
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() {
|
||||
pending--;
|
||||
if (closed && pending == 0) drained.complete(null);
|
||||
}
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() {
|
||||
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Quickstep;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Serialized durable Quickstep settings; publication remains a server-thread caller decision. */
|
||||
public final class QuickstepSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "quickstep-cooldown-seconds";
|
||||
public static final String DISTANCE = "quickstep-distance-blocks";
|
||||
public static final String SPEED = "quickstep-speed-blocks-per-tick";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, DISTANCE, SPEED);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "5", DISTANCE, "4", SPEED, "1");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Quickstep.Settings settings,
|
||||
Map<String, String> values, long sequence) {
|
||||
public Update { values = Map.copyOf(values); }
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-quickstep-configuration"));
|
||||
private volatile Map<String, String> effectiveValues;
|
||||
private Map<String, String> persistedValues;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public QuickstepSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
QuickstepSettings(Path directory, Map<String, String> initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
var copy = new LinkedHashMap<String, String>();
|
||||
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
|
||||
parse(copy);
|
||||
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
|
||||
}
|
||||
|
||||
public static List<String> keys() { return KEYS; }
|
||||
public static String defaultValue(String key) { return DEFAULTS.get(key); }
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public Quickstep.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
Objects.requireNonNull(key); Objects.requireNonNull(value);
|
||||
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
|
||||
String accepted = canonical(key, value);
|
||||
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
|
||||
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
var next = new LinkedHashMap<>(persistedValues);
|
||||
next.put(key, accepted);
|
||||
var parsed = parse(next);
|
||||
save(key, accepted);
|
||||
persistedValues = Map.copyOf(next);
|
||||
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Quickstep configuration save refused or failed; effective settings unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String key, String raw) {
|
||||
try {
|
||||
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static Quickstep.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
double distance = finite(values.get(DISTANCE));
|
||||
double speed = finite(values.get(SPEED));
|
||||
return new Quickstep.Settings(cooldown, distance, speed);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Quickstep settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new java.math.BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void save(String key, String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml");
|
||||
var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options));
|
||||
Map<String, Object> document = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
|
||||
document.put(savedKey, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(key, value);
|
||||
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() {
|
||||
pending--;
|
||||
if (closed && pending == 0) drained.complete(null);
|
||||
}
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() {
|
||||
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Feature-scoped Revive interruption proof; never authority to guess or mint an item. */
|
||||
public final class RevivalStore implements AutoCloseable {
|
||||
private static final int MAX_FILE_BYTES = 32_768;
|
||||
private static final int MAX_FILES = 4096;
|
||||
private static final Set<String> KEYS = Set.of("schema-version", "reservation-id", "caster-character-id",
|
||||
"target-player-id", "character-id", "death-id", "created-at", "source-slot", "source-amount", "destination-slot");
|
||||
public record Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
|
||||
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot) {
|
||||
public Reservation {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(casterCharacter); Objects.requireNonNull(targetPlayer);
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(death); Objects.requireNonNull(createdAt);
|
||||
if (createdAt.getNano() != 0 || sourceSlot < 0 || sourceSlot > 40 || sourceSlot > 35 && sourceSlot != 40
|
||||
|| sourceAmount < 1 || sourceAmount > 99 || destinationSlot < -1 || destinationSlot > 40
|
||||
|| destinationSlot > 35 && destinationSlot != 40 || destinationSlot == sourceSlot) {
|
||||
throw new IllegalArgumentException("Invalid revival reservation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-revival-persistence"));
|
||||
|
||||
public RevivalStore(Path pluginDataDirectory) {
|
||||
directory = Objects.requireNonNull(pluginDataDirectory).resolve("revivals").toAbsolutePath();
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<Reservation>> reserve(Reservation reservation) {
|
||||
Objects.requireNonNull(reservation);
|
||||
return submit(() -> {
|
||||
Path file = file(reservation.character());
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
safeDirectory();
|
||||
Path temporary = directory.resolve("." + reservation.character() + "." + UUID.randomUUID() + ".tmp");
|
||||
try {
|
||||
Files.writeString(temporary, encode(reservation), StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
|
||||
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
return Optional.of(reservation);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<Reservation>> find(UUID character) {
|
||||
Objects.requireNonNull(character);
|
||||
return submit(() -> load(file(character)));
|
||||
}
|
||||
|
||||
public CompletableFuture<List<Reservation>> target(UUID player) {
|
||||
Objects.requireNonNull(player);
|
||||
return submit(() -> {
|
||||
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return List.of();
|
||||
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unsafe revival directory");
|
||||
}
|
||||
var result = new ArrayList<Reservation>();
|
||||
try (var files = Files.list(directory)) {
|
||||
for (var path : files.limit(MAX_FILES + 1).toList()) {
|
||||
if (result.size() >= MAX_FILES) throw new IOException("Too many revival reservations");
|
||||
if (!path.getFileName().toString().endsWith(".yml")) continue;
|
||||
var reservation = load(path).orElseThrow(() -> new IOException("Missing revival reservation"));
|
||||
if (reservation.targetPlayer().equals(player)) result.add(reservation);
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> clear(UUID character, UUID reservation) {
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(reservation);
|
||||
return submit(() -> {
|
||||
var found = load(file(character));
|
||||
if (found.isEmpty() || !found.orElseThrow().id().equals(reservation)) return false;
|
||||
return Files.deleteIfExists(file(character));
|
||||
});
|
||||
}
|
||||
|
||||
private void safeDirectory() throws IOException {
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unsafe revival directory");
|
||||
}
|
||||
} else Files.createDirectories(directory);
|
||||
}
|
||||
|
||||
private Path file(UUID character) { return directory.resolve(character + ".yml"); }
|
||||
|
||||
private Optional<Reservation> load(Path file) throws IOException {
|
||||
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)
|
||||
|| Files.size(file) > MAX_FILE_BYTES) throw new IOException("Unsafe revival reservation");
|
||||
Object decoded;
|
||||
try {
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(0);
|
||||
decoded = new Yaml(new SafeConstructor(options)).load(Files.readString(file, StandardCharsets.UTF_8));
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); }
|
||||
if (!(decoded instanceof Map<?, ?> raw) || !raw.keySet().equals(KEYS)) throw new IOException("Invalid revival reservation keys");
|
||||
try {
|
||||
if (!"1".equals(text(raw, "schema-version"))) throw new IllegalArgumentException("schema");
|
||||
var value = new Reservation(uuid(raw, "reservation-id"), uuid(raw, "caster-character-id"),
|
||||
uuid(raw, "target-player-id"), uuid(raw, "character-id"), uuid(raw, "death-id"),
|
||||
Instant.parse(text(raw, "created-at")), integer(raw, "source-slot"), integer(raw, "source-amount"),
|
||||
integer(raw, "destination-slot"));
|
||||
if (!file.getFileName().toString().equals(value.character() + ".yml")) throw new IllegalArgumentException("identity");
|
||||
return Optional.of(value);
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); }
|
||||
}
|
||||
|
||||
private static String encode(Reservation value) {
|
||||
var map = new LinkedHashMap<String, Object>();
|
||||
map.put("schema-version", "1"); map.put("reservation-id", value.id().toString());
|
||||
map.put("caster-character-id", value.casterCharacter().toString()); map.put("target-player-id", value.targetPlayer().toString());
|
||||
map.put("character-id", value.character().toString()); map.put("death-id", value.death().toString());
|
||||
map.put("created-at", value.createdAt().toString()); map.put("source-slot", Integer.toString(value.sourceSlot()));
|
||||
map.put("source-amount", Integer.toString(value.sourceAmount())); map.put("destination-slot", Integer.toString(value.destinationSlot()));
|
||||
return new Yaml().dump(map);
|
||||
}
|
||||
|
||||
private static String text(Map<?, ?> map, String key) {
|
||||
Object value = map.get(key); if (!(value instanceof String text) || text.isBlank()) throw new IllegalArgumentException(key); return text;
|
||||
}
|
||||
private static UUID uuid(Map<?, ?> map, String key) { var value = UUID.fromString(text(map, key)); if (!value.toString().equals(text(map, key))) throw new IllegalArgumentException(key); return value; }
|
||||
private static int integer(Map<?, ?> map, String key) { return Integer.parseInt(text(map, key)); }
|
||||
|
||||
@FunctionalInterface private interface Work<T> { T run() throws IOException; }
|
||||
private <T> CompletableFuture<T> submit(Work<T> work) {
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> { try { return work.run(); } catch (IOException error) { throw new CompletionException(error); } }, worker);
|
||||
} catch (RejectedExecutionException rejected) { return CompletableFuture.failedFuture(rejected); }
|
||||
}
|
||||
@Override public void close() { worker.shutdown(); }
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.SecondWind;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Serialized durable SecondWind settings; publication remains a server-thread caller decision. */
|
||||
public final class SecondWindSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_HEALING = "second-wind-base-healing";
|
||||
public static final String COOLDOWN = "second-wind-cooldown-seconds";
|
||||
private static final List<String> KEYS = List.of(BASE_HEALING, COOLDOWN);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_HEALING, "6", COOLDOWN, "15");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, SecondWind.Settings settings,
|
||||
Map<String, String> values, long sequence) {
|
||||
public Update { values = Map.copyOf(values); }
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-second-wind-configuration"));
|
||||
private volatile Map<String, String> effectiveValues;
|
||||
private Map<String, String> persistedValues;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public SecondWindSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
SecondWindSettings(Path directory, Map<String, String> initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
var copy = new LinkedHashMap<String, String>();
|
||||
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
|
||||
parse(copy);
|
||||
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
|
||||
}
|
||||
|
||||
public static List<String> keys() { return KEYS; }
|
||||
public static String defaultValue(String key) { return DEFAULTS.get(key); }
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public SecondWind.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
Objects.requireNonNull(key); Objects.requireNonNull(value);
|
||||
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
|
||||
String accepted = canonical(key, value);
|
||||
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
|
||||
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
var next = new LinkedHashMap<>(persistedValues);
|
||||
next.put(key, accepted);
|
||||
var parsed = parse(next);
|
||||
save(key, accepted);
|
||||
persistedValues = Map.copyOf(next);
|
||||
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("SecondWind configuration save refused or failed; effective settings unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String key, String raw) {
|
||||
try {
|
||||
if (key.equals(BASE_HEALING)) return new java.math.BigInteger(raw).toString();
|
||||
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static SecondWind.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var base = new java.math.BigInteger(values.get(BASE_HEALING));
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
return new SecondWind.Settings(base, cooldown);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("SecondWind settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new java.math.BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void save(String key, String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml");
|
||||
var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options));
|
||||
Map<String, Object> document = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
|
||||
document.put(savedKey, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(key, value);
|
||||
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() {
|
||||
pending--;
|
||||
if (closed && pending == 0) drained.complete(null);
|
||||
}
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() {
|
||||
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.StoneSkin;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Serialized durable Stone Skin settings; publication remains a server-thread caller decision. */
|
||||
public final class StoneSkinSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "stone-skin-cooldown-seconds";
|
||||
public static final String DURATION = "stone-skin-duration-seconds";
|
||||
public static final String REDUCTION = "stone-skin-damage-reduction-percent";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, DURATION, REDUCTION);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "14", DURATION, "4", REDUCTION, "25");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, StoneSkin.Settings settings,
|
||||
Map<String, String> values, long sequence) {
|
||||
public Update { values = Map.copyOf(values); }
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-stone-skin-configuration"));
|
||||
private volatile Map<String, String> effectiveValues;
|
||||
private Map<String, String> persistedValues;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public StoneSkinSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
StoneSkinSettings(Path directory, Map<String, String> initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
var copy = new LinkedHashMap<String, String>();
|
||||
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
|
||||
parse(copy);
|
||||
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
|
||||
}
|
||||
|
||||
public static List<String> keys() { return KEYS; }
|
||||
public static String defaultValue(String key) { return DEFAULTS.get(key); }
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public StoneSkin.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
Objects.requireNonNull(key); Objects.requireNonNull(value);
|
||||
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
|
||||
String accepted = canonical(key, value);
|
||||
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
|
||||
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
var next = new LinkedHashMap<>(persistedValues);
|
||||
next.put(key, accepted);
|
||||
var parsed = parse(next);
|
||||
save(key, accepted);
|
||||
persistedValues = Map.copyOf(next);
|
||||
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Stone Skin configuration save refused or failed; effective settings unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String key, String raw) {
|
||||
try {
|
||||
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static StoneSkin.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
var duration = seconds(values.get(DURATION));
|
||||
var reduction = new java.math.BigDecimal(values.get(REDUCTION));
|
||||
return new StoneSkin.Settings(cooldown, duration, reduction);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Stone Skin settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new java.math.BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void save(String key, String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml");
|
||||
var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options));
|
||||
Map<String, Object> document = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
|
||||
document.put(savedKey, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(key, value);
|
||||
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() {
|
||||
pending--;
|
||||
if (closed && pending == 0) drained.complete(null);
|
||||
}
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() {
|
||||
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Server-thread bounded contribution ledger keyed by target and originating character. */
|
||||
public final class CombatContributions implements AutoCloseable {
|
||||
private final CombatRewardRules rules;
|
||||
private final Map<UUID, Map<UUID, Instant>> targets = new HashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
public CombatContributions(CombatRewardRules rules) { this.rules = Objects.requireNonNull(rules); }
|
||||
|
||||
public void accept(UUID target, UUID character, Instant acceptedAt) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(character); Objects.requireNonNull(acceptedAt);
|
||||
if (closed) return;
|
||||
targets.computeIfAbsent(target, ignored -> new HashMap<>()).merge(character, acceptedAt,
|
||||
(previous, next) -> previous.isAfter(next) ? previous : next);
|
||||
}
|
||||
|
||||
public Set<UUID> consume(UUID target, Instant deathAt) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(deathAt);
|
||||
if (closed) return Set.of();
|
||||
var contributions = targets.remove(target);
|
||||
if (contributions == null) return Set.of();
|
||||
var eligible = new LinkedHashSet<UUID>();
|
||||
contributions.forEach((character, acceptedAt) -> {
|
||||
if (rules.contributes(acceptedAt, deathAt)) eligible.add(character);
|
||||
});
|
||||
return Set.copyOf(eligible);
|
||||
}
|
||||
|
||||
public void invalidate(UUID target) { if (!closed) targets.remove(Objects.requireNonNull(target)); }
|
||||
|
||||
public void sweep(Instant now) {
|
||||
Objects.requireNonNull(now);
|
||||
if (closed) return;
|
||||
targets.values().forEach(contributions -> contributions.entrySet().removeIf(entry -> !rules.contributes(entry.getValue(), now)));
|
||||
targets.values().removeIf(Map::isEmpty);
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; targets.clear(); }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Validated combat reward arithmetic and location policy; native classification lives at the adapter. */
|
||||
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
|
||||
BigDecimal restrictedSpacing) {
|
||||
public enum Target { PASSIVE, ORDINARY, BOSS, FANTASY_CHARACTER }
|
||||
public static final Duration CONTRIBUTION_LIFETIME = Duration.ofMinutes(5);
|
||||
|
||||
public record KillPosition(UUID world, BigDecimal x, BigDecimal y, BigDecimal z) {
|
||||
public KillPosition {
|
||||
Objects.requireNonNull(world);
|
||||
x = coordinate(x); y = coordinate(y); z = coordinate(z);
|
||||
}
|
||||
private static BigDecimal coordinate(BigDecimal value) {
|
||||
Objects.requireNonNull(value);
|
||||
if (!Double.isFinite(value.doubleValue())) throw new IllegalArgumentException("Kill coordinate is not finite");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public CombatRewardRules {
|
||||
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool);
|
||||
Objects.requireNonNull(bossPool); Objects.requireNonNull(restrictedSpacing);
|
||||
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || bossPool.signum() < 0
|
||||
|| restrictedSpacing.signum() < 0 || !Double.isFinite(restrictedSpacing.doubleValue())) {
|
||||
throw new IllegalArgumentException("Combat reward values must be finite and nonnegative");
|
||||
}
|
||||
}
|
||||
|
||||
public static CombatRewardRules defaults() {
|
||||
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), BigDecimal.valueOf(3));
|
||||
}
|
||||
|
||||
public BigInteger pool(Target target, int defeatedLevel) {
|
||||
Objects.requireNonNull(target);
|
||||
if (target == Target.FANTASY_CHARACTER) {
|
||||
if (defeatedLevel < 1) throw new IllegalArgumentException("Fantasy character level must be positive");
|
||||
return BigInteger.TEN.multiply(BigInteger.valueOf(defeatedLevel));
|
||||
}
|
||||
return switch (target) {
|
||||
case PASSIVE -> passivePool;
|
||||
case ORDINARY -> ordinaryPool;
|
||||
case BOSS -> bossPool;
|
||||
case FANTASY_CHARACTER -> throw new IllegalStateException("Handled fantasy character");
|
||||
};
|
||||
}
|
||||
|
||||
public BigInteger share(BigInteger pool, int contributors) {
|
||||
Objects.requireNonNull(pool);
|
||||
if (pool.signum() < 0 || contributors < 1) throw new IllegalArgumentException("Invalid reward share");
|
||||
var count = BigInteger.valueOf(contributors);
|
||||
return pool.add(count).subtract(BigInteger.ONE).divide(count);
|
||||
}
|
||||
|
||||
public boolean contributes(Instant acceptedAt, Instant deathAt) {
|
||||
Objects.requireNonNull(acceptedAt); Objects.requireNonNull(deathAt);
|
||||
return !acceptedAt.isAfter(deathAt) && !deathAt.isAfter(acceptedAt.plus(CONTRIBUTION_LIFETIME));
|
||||
}
|
||||
|
||||
public boolean spaced(KillPosition previous, KillPosition current) {
|
||||
Objects.requireNonNull(previous); Objects.requireNonNull(current);
|
||||
if (!previous.world().equals(current.world())) return true;
|
||||
BigDecimal dx = current.x().subtract(previous.x());
|
||||
BigDecimal dy = current.y().subtract(previous.y());
|
||||
BigDecimal dz = current.z().subtract(previous.z());
|
||||
BigDecimal squared = dx.multiply(dx).add(dy.multiply(dy)).add(dz.multiply(dz));
|
||||
return squared.compareTo(restrictedSpacing.multiply(restrictedSpacing)) >= 0;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,17 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private final Consumer<Failure> diagnostics;
|
||||
private volatile boolean closed;
|
||||
private games.dmg.fantasystick.persistence.AdventureSettings settings;
|
||||
private games.dmg.fantasystick.persistence.FrostShardSettings frostSettings;
|
||||
private games.dmg.fantasystick.ability.FrostShard frostShard;
|
||||
private games.dmg.fantasystick.ability.AbilityRegistry abilities;
|
||||
private games.dmg.fantasystick.persistence.LongbowSettings longbowSettings;
|
||||
private games.dmg.fantasystick.ability.Longbow longbow;
|
||||
private games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings;
|
||||
private games.dmg.fantasystick.ability.Quickstep quickstep;
|
||||
private games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings;
|
||||
private games.dmg.fantasystick.ability.SecondWind secondWind;
|
||||
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
|
||||
private games.dmg.fantasystick.ability.StoneSkin stoneSkin;
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
|
||||
@@ -38,6 +49,85 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
this.settings = Objects.requireNonNull(settings);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings,
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
|
||||
games.dmg.fantasystick.ability.FrostShard frostShard,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings);
|
||||
this.frostSettings = Objects.requireNonNull(frostSettings);
|
||||
this.frostShard = Objects.requireNonNull(frostShard);
|
||||
this.abilities = Objects.requireNonNull(abilities);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings,
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
|
||||
games.dmg.fantasystick.ability.FrostShard frostShard,
|
||||
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
|
||||
games.dmg.fantasystick.ability.Longbow longbow,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard, abilities);
|
||||
this.longbowSettings = Objects.requireNonNull(longbowSettings);
|
||||
this.longbow = Objects.requireNonNull(longbow);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings,
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
|
||||
games.dmg.fantasystick.ability.FrostShard frostShard,
|
||||
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
|
||||
games.dmg.fantasystick.ability.Longbow longbow,
|
||||
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
|
||||
games.dmg.fantasystick.ability.Quickstep quickstep,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
|
||||
longbowSettings, longbow, abilities);
|
||||
this.quickstepSettings = Objects.requireNonNull(quickstepSettings);
|
||||
this.quickstep = Objects.requireNonNull(quickstep);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings,
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
|
||||
games.dmg.fantasystick.ability.FrostShard frostShard,
|
||||
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
|
||||
games.dmg.fantasystick.ability.Longbow longbow,
|
||||
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
|
||||
games.dmg.fantasystick.ability.Quickstep quickstep,
|
||||
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
|
||||
games.dmg.fantasystick.ability.SecondWind secondWind,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
|
||||
longbowSettings, longbow, quickstepSettings, quickstep, abilities);
|
||||
this.secondWindSettings = Objects.requireNonNull(secondWindSettings);
|
||||
this.secondWind = Objects.requireNonNull(secondWind);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings,
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
|
||||
games.dmg.fantasystick.ability.FrostShard frostShard,
|
||||
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
|
||||
games.dmg.fantasystick.ability.Longbow longbow,
|
||||
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
|
||||
games.dmg.fantasystick.ability.Quickstep quickstep,
|
||||
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
|
||||
games.dmg.fantasystick.ability.SecondWind secondWind,
|
||||
games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings,
|
||||
games.dmg.fantasystick.ability.StoneSkin stoneSkin,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
|
||||
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind, abilities);
|
||||
this.stoneSkinSettings = Objects.requireNonNull(stoneSkinSettings);
|
||||
this.stoneSkin = Objects.requireNonNull(stoneSkin);
|
||||
}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (closed) return true;
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
@@ -109,15 +199,180 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
private void configuration(CommandSender sender, String[] args) {
|
||||
if (args.length < 2 || args.length > 3 || !args[1].equals("adventure-mode")
|
||||
|| args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
|
||||
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]");
|
||||
if (args.length < 2 || args.length > 3) { configurationUsage(sender); return; }
|
||||
if (args[1].equals("adventure-mode")) { adventureConfiguration(sender, args); return; }
|
||||
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
|
||||
longbowConfiguration(sender, args); return;
|
||||
}
|
||||
if (quickstepSettings != null && games.dmg.fantasystick.persistence.QuickstepSettings.keys().contains(args[1])) {
|
||||
quickstepConfiguration(sender, args); return;
|
||||
}
|
||||
if (secondWindSettings != null && games.dmg.fantasystick.persistence.SecondWindSettings.keys().contains(args[1])) {
|
||||
secondWindConfiguration(sender, args); return;
|
||||
}
|
||||
if (stoneSkinSettings != null && games.dmg.fantasystick.persistence.StoneSkinSettings.keys().contains(args[1])) {
|
||||
stoneSkinConfiguration(sender, args); return;
|
||||
}
|
||||
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
|
||||
configurationUsage(sender); return;
|
||||
}
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + frostSettings.value(key) + " (applies to future Frost Shard casts only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostShardSettings.Update> operation;
|
||||
try { operation = frostSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Frost Shard setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
frostShard.configure(saved.settings());
|
||||
abilities.replace(frostShard.definition());
|
||||
frostSettings.publish(saved);
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Frost Shard casts; active effects and deadlines are unchanged."
|
||||
: "Frost Shard configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void longbowConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + longbowSettings.value(key) + " (applies to future Longbow shots only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.LongbowSettings.Update> operation;
|
||||
try { operation = longbowSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Longbow setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
longbow.configure(saved.settings());
|
||||
abilities.replace(longbow.definition());
|
||||
longbowSettings.publish(saved);
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Longbow shots; active shots and deadlines are unchanged."
|
||||
: "Longbow configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void quickstepConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + quickstepSettings.value(key) + " (applies to future Quickstep dashes only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickstepSettings.Update> operation;
|
||||
try { operation = quickstepSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Quickstep setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
quickstep.configure(saved.settings()); abilities.replace(quickstep.definition());
|
||||
quickstepSettings.publish(saved);
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Quickstep dashes; active movement and deadlines are unchanged."
|
||||
: "Quickstep configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void stoneSkinConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + stoneSkinSettings.value(key) + " (applies to future Stone Skin casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.StoneSkinSettings.Update> operation;
|
||||
try { operation = stoneSkinSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) { sender.sendMessage("Stone Skin setting is invalid; the effective value is unchanged."); return; }
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
stoneSkin.configure(saved.settings()); abilities.replace(stoneSkin.definition()); stoneSkinSettings.publish(saved);
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Stone Skin casts; active effects and deadlines are unchanged."
|
||||
: "Stone Skin configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void secondWindConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + secondWindSettings.value(key) + " (applies to future Second Wind casts only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SecondWindSettings.Update> operation;
|
||||
try { operation = secondWindSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Second Wind setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
secondWind.configure(saved.settings()); abilities.replace(secondWind.definition());
|
||||
secondWindSettings.publish(saved);
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Second Wind casts; active work and deadlines are unchanged."
|
||||
: "Second Wind configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void adventureConfiguration(CommandSender sender, String[] args) {
|
||||
if (args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
|
||||
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]"); return;
|
||||
}
|
||||
if (settings == null) { sender.sendMessage("Adventure configuration is unavailable."); return; }
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage("adventure-mode=" + settings.enabled() + " (applies to new sessions only)");
|
||||
return;
|
||||
sender.sendMessage("adventure-mode=" + settings.enabled() + " (applies to new sessions only)"); return;
|
||||
}
|
||||
settings.set(Boolean.parseBoolean(args[2])).whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-adventure-mode", failure);
|
||||
@@ -134,6 +389,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
private static void configurationUsage(CommandSender sender) {
|
||||
sender.sendMessage("Usage: /fantasystick config <setting> [value]");
|
||||
}
|
||||
|
||||
private void report(UUID owner, String operation, Throwable cause) {
|
||||
try { diagnostics.accept(new Failure(owner, operation, cause)); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics cannot authorize recovery. */ }
|
||||
@@ -141,7 +400,50 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
// Never fall back to Bukkit's online-player-name suggestions for a UUID-only admin interface.
|
||||
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (closed || !sender.hasPermission(PERMISSION)) return List.of();
|
||||
if (args.length == 1) return matching(List.of("config", "inspect", "recover"), args[0]);
|
||||
if (args.length == 2 && args[0].equals("config")) {
|
||||
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");
|
||||
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.SecondWindSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.StoneSkinSettings.keys());
|
||||
return matching(keys, args[1]);
|
||||
}
|
||||
if (args.length == 3 && args[0].equals("config")) {
|
||||
if (args[1].equals("adventure-mode")) return matching(List.of("false", "true"), args[2]);
|
||||
if (frostSettings != null && games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
|
||||
String current = frostSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
|
||||
String current = longbowSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.LongbowSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (quickstepSettings != null && games.dmg.fantasystick.persistence.QuickstepSettings.keys().contains(args[1])) {
|
||||
String current = quickstepSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.QuickstepSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (secondWindSettings != null && games.dmg.fantasystick.persistence.SecondWindSettings.keys().contains(args[1])) {
|
||||
String current = secondWindSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.SecondWindSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (stoneSkinSettings != null && games.dmg.fantasystick.persistence.StoneSkinSettings.keys().contains(args[1])) {
|
||||
String current = stoneSkinSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.StoneSkinSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private static List<String> matching(java.util.Collection<String> values, String prefix) {
|
||||
return values.stream().filter(value -> value.startsWith(prefix)).distinct().sorted().toList();
|
||||
}
|
||||
@Override public void close() { closed = true; }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import games.dmg.fantasystick.items.SessionControls;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
@@ -45,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<>();
|
||||
@@ -141,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");
|
||||
@@ -201,6 +208,42 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
|
||||
}
|
||||
|
||||
/** Current active holder for a transferable identity, used only to synchronize acknowledged character writes. */
|
||||
public synchronized Optional<Active> activeCharacter(UUID character) {
|
||||
Objects.requireNonNull(character);
|
||||
return active.values().stream().filter(attempt -> attempt.record != null && attempt.character.equals(character))
|
||||
.filter(attempt -> current(new Active(attempt.owner, attempt.session, attempt.character)))
|
||||
.map(attempt -> new Active(attempt.owner, attempt.session, attempt.character)).findFirst();
|
||||
}
|
||||
|
||||
/** Current transferable character snapshot for a still-authorized server-thread action. */
|
||||
public synchronized Optional<CharacterRecord> currentRecord(Active session) {
|
||||
var attempt = active.get(session.player());
|
||||
return current(session) && attempt != null && attempt.record != null
|
||||
? Optional.of(attempt.record) : Optional.empty();
|
||||
}
|
||||
|
||||
/** Apply a persisted progression revision to the still-current active session. */
|
||||
public synchronized boolean acknowledgeProgress(Active session, BigInteger expectedRevision, CharacterRecord saved) {
|
||||
var attempt = active.get(session.player());
|
||||
if (closed || attempt == null || attempt.session == null || attempt.record == null
|
||||
|| !attempt.session.equals(session.session()) || !attempt.character.equals(session.character())
|
||||
|| !attempt.record.revision().equals(expectedRevision) || !saved.id().equals(attempt.character)
|
||||
|| !saved.revision().equals(expectedRevision.add(BigInteger.ONE)) || !current(session)) return false;
|
||||
var previous = attempt.record;
|
||||
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
|
||||
attempt.record = saved; // 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;
|
||||
}
|
||||
|
||||
/** Server-thread authorization for the currently held, issued control and saved loadout. */
|
||||
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
|
||||
var attempt = active.get(player.getUniqueId());
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
|
||||
/** Active-session-only hunger/exhaustion suppression; explicit non-food healing remains native. */
|
||||
public final class NutritionPolicy implements Listener, AutoCloseable {
|
||||
private final EquipmentSessions sessions;
|
||||
private boolean closed;
|
||||
|
||||
public NutritionPolicy(EquipmentSessions sessions) {
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void food(org.bukkit.event.entity.FoodLevelChangeEvent event) {
|
||||
if (!active(event.getEntity())) return;
|
||||
event.setCancelled(true);
|
||||
var player = (Player) event.getEntity();
|
||||
player.setFoodLevel(20);
|
||||
player.setExhaustion(0);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void exhaustion(org.bukkit.event.entity.EntityExhaustionEvent event) {
|
||||
if (!active(event.getEntity())) return;
|
||||
event.setCancelled(true);
|
||||
var player = (Player) event.getEntity();
|
||||
player.setFoodLevel(20);
|
||||
player.setExhaustion(0);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void healing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
|
||||
if (event.getRegainReason() == org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED
|
||||
&& active(event.getEntity())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
private boolean active(org.bukkit.entity.Entity entity) {
|
||||
return !closed && entity instanceof Player player
|
||||
&& sessions.active(player.getUniqueId()).isPresent();
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; }
|
||||
}
|
||||
@@ -57,10 +57,15 @@ public final class SessionVitals implements AutoCloseable {
|
||||
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
|
||||
player.setHealthScale(maximum);
|
||||
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
|
||||
player.setFoodLevel(20);
|
||||
player.setExhaustion(0);
|
||||
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
|
||||
write(player, new Mark(session, Phase.APPLIED, maximum, ordinary));
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
try { releaseMaximum(player, session); }
|
||||
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
|
||||
try { restoreNutrition(player, ordinary); }
|
||||
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
|
||||
try { restoreScale(player, maximum, ordinary); }
|
||||
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
|
||||
// Leave APPLYING proof and the acknowledged receipt: an uncertain health setter
|
||||
@@ -69,6 +74,30 @@ public final class SessionVitals implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply an acknowledged stat revision to the live native health pool exactly once. */
|
||||
public void updateProgress(Player player, UUID session, CharacterRecord previous, CharacterRecord saved) {
|
||||
Objects.requireNonNull(previous);
|
||||
Objects.requireNonNull(saved);
|
||||
var mark = read(player);
|
||||
var lease = leases.get(player.getUniqueId());
|
||||
if (closed || mark == null || mark.phase() != Phase.APPLIED || !mark.session().equals(session)
|
||||
|| lease == null || !lease.session().equals(session) || !previous.id().equals(saved.id())
|
||||
|| !previous.revision().add(java.math.BigInteger.ONE).equals(saved.revision())) {
|
||||
throw new IllegalStateException("No matching active fantasy vitals revision");
|
||||
}
|
||||
double nextMaximum = saved.maximumHealth().min(NativeHealthLimit.read()).doubleValue();
|
||||
double gain = Math.max(0, nextMaximum - mark.maximum());
|
||||
if (gain == 0 && nextMaximum == mark.maximum()) return;
|
||||
var nativeAttribute = Objects.requireNonNull(((CraftPlayer) player).getHandle()
|
||||
.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
|
||||
lease.maximum().close();
|
||||
var replacement = new NativeMaximumHealth(nativeAttribute, nextMaximum);
|
||||
leases.put(player.getUniqueId(), new Lease(session, replacement));
|
||||
player.setHealth(Math.min(nextMaximum, player.getHealth() + gain));
|
||||
player.setHealthScale(nextMaximum);
|
||||
write(player, new Mark(session, Phase.APPLIED, nextMaximum, mark.ordinary()));
|
||||
}
|
||||
|
||||
public BigDecimal remaining(Player player, UUID session, OrdinaryVitals ordinary) {
|
||||
var mark = read(player);
|
||||
if (mark == null || !mark.session().equals(session) || !mark.ordinary().equals(ordinary) || mark.phase() != Phase.APPLIED) {
|
||||
@@ -97,7 +126,7 @@ public final class SessionVitals implements AutoCloseable {
|
||||
// completion). Restore positive health first: a zero-health packet opens a client death
|
||||
// screen even when Paper cancels the death and the server-side player survives.
|
||||
player.setHealth(Math.min(ordinary.health(), Objects.requireNonNull(player.getAttribute(Attribute.MAX_HEALTH)).getValue()));
|
||||
player.setFoodLevel(ordinary.food()); player.setSaturation(ordinary.saturation()); player.setExhaustion(ordinary.exhaustion());
|
||||
restoreNutrition(player, ordinary);
|
||||
restoreScale(player, mark.maximum(), ordinary);
|
||||
write(player, new Mark(session, Phase.RELEASED, mark.maximum(), ordinary));
|
||||
return true;
|
||||
@@ -111,6 +140,12 @@ public final class SessionVitals implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private static void restoreNutrition(Player player, OrdinaryVitals ordinary) {
|
||||
player.setFoodLevel(ordinary.food());
|
||||
player.setSaturation(ordinary.saturation());
|
||||
player.setExhaustion(ordinary.exhaustion());
|
||||
}
|
||||
|
||||
private static void restoreScale(Player player, double maximum, OrdinaryVitals ordinary) {
|
||||
if (player.isHealthScaled() && player.getHealthScale() == maximum) {
|
||||
player.setHealthScale(ordinary.scale());
|
||||
|
||||
@@ -1,3 +1,64 @@
|
||||
# Captured when a new fantasy session starts; changing this does not alter active sessions.
|
||||
# Admin command: /fantasystick config adventure-mode [true|false]
|
||||
adventure-mode: true
|
||||
|
||||
# Real-time delay before this player may enter a different character after exit.
|
||||
character-swap-cooldown-seconds: 900
|
||||
|
||||
# New-character and transferable fantasy progression defaults.
|
||||
starting-points: 40
|
||||
xp-per-level: 100
|
||||
maximum-level: 30
|
||||
|
||||
# Character-bound combat rewards. Restricted hostile/neutral/boss history uses mob death positions.
|
||||
passive-xp: 1
|
||||
ordinary-xp: 10
|
||||
boss-xp: 100
|
||||
restricted-kill-spacing-blocks: 3
|
||||
|
||||
# Fireball defaults from the reviewed starter catalogue.
|
||||
fireball-base-damage: 6
|
||||
fireball-cooldown-seconds: 4
|
||||
fireball-range-blocks: 32
|
||||
fireball-lifetime-seconds: 5
|
||||
|
||||
# Great Sword defaults from the reviewed starter catalogue.
|
||||
great-sword-base-damage: 7
|
||||
great-sword-recovery-seconds: 1.2
|
||||
|
||||
# Frost Shard defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
frost-shard-base-damage: 4
|
||||
frost-shard-cooldown-seconds: 5
|
||||
frost-shard-range-blocks: 32
|
||||
frost-shard-lifetime-seconds: 5
|
||||
frost-shard-projectile-speed-blocks-per-tick: 1
|
||||
frost-shard-slowness-amplifier: 0
|
||||
frost-shard-slowness-duration-seconds: 2
|
||||
|
||||
# Longbow defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
longbow-base-damage: 5
|
||||
longbow-recovery-seconds: 1
|
||||
longbow-minimum-draw-ticks: 3
|
||||
longbow-full-draw-ticks: 20
|
||||
longbow-range-blocks: 64
|
||||
longbow-lifetime-seconds: 10
|
||||
longbow-full-projectile-speed-blocks-per-tick: 3
|
||||
|
||||
# Quickstep defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
quickstep-cooldown-seconds: 5
|
||||
quickstep-distance-blocks: 4
|
||||
quickstep-speed-blocks-per-tick: 1
|
||||
|
||||
# Second Wind defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
second-wind-base-healing: 6
|
||||
second-wind-cooldown-seconds: 15
|
||||
|
||||
# Stone Skin defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
stone-skin-cooldown-seconds: 14
|
||||
stone-skin-duration-seconds: 4
|
||||
stone-skin-damage-reduction-percent: 25
|
||||
@@ -7,8 +7,8 @@ description: Transferable characters, native fantasy health, recoverable equipme
|
||||
author: dmg.games
|
||||
commands:
|
||||
fantasystick:
|
||||
description: Inspect/recover equipment sessions or configure fantasy mode.
|
||||
usage: /fantasystick <inspect|recover> <player-uuid> | config adventure-mode [true|false]
|
||||
description: Inspect/recover equipment sessions or configure Fantasy Stick.
|
||||
usage: /fantasystick <inspect|recover> <player-uuid> | config <setting> [value]
|
||||
permission: fantasystick.admin
|
||||
permissions:
|
||||
fantasystick.admin:
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class BasicAttackNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void successfulHitsStartAnExactOneSecondCadenceButCancelledDamageDoesNot() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(a.core.id).orElseThrow();
|
||||
var clock = new MutableClock(Instant.parse("2026-09-19T00:00:00Z"));
|
||||
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
doAnswer(call -> {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getCausingEntity()).thenReturn(a.player);
|
||||
when(source.getDirectEntity()).thenReturn(a.player);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
|
||||
event.setCancelled(cancelled.get());
|
||||
basic.acceptedDamage(event);
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), same(a.player));
|
||||
var input = new games.dmg.fantasystick.session.ControlInput(a.player, active, 1, "",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1,
|
||||
() -> sessions.current(active));
|
||||
basic.accept(input); // Cancelled: no cadence spent.
|
||||
cancelled.set(false);
|
||||
basic.accept(input); // Accepted at t=0.
|
||||
clock.advance(java.time.Duration.ofMillis(999)); basic.accept(input);
|
||||
verify(target, times(2)).damage(1.0, a.player);
|
||||
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
|
||||
verify(target, times(3)).damage(1.0, a.player);
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
|
||||
verifyNoMoreInteractions(target);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partyCancellationPreventsDamageAndDoesNotSpendThePackagedAttackCadence() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
var party = parties.create(a.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
|
||||
var events = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getCausingEntity()).thenReturn(a.player);
|
||||
when(source.getDirectEntity()).thenReturn(a.player);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, b.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
|
||||
events.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(b.player).damage(anyDouble(), same(a.player));
|
||||
a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
assertEquals(1, events.size()); assertTrue(events.getFirst().isCancelled());
|
||||
assertTrue(parties.leave(a.core.id, party.id()));
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
assertEquals(2, events.size()); assertFalse(events.getLast().isCancelled());
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
assertEquals(2, events.size(), "The accepted second hit starts cadence; the cancelled first hit did not");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedBasicStickInputDealsOneLowStrengthHitWithoutVanillaContinuation() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(attack);
|
||||
assertTrue(attack.isCancelled(), "The icon stick must never continue into vanilla damage");
|
||||
verify(target).damage(1.0, a.player);
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
|
||||
assertTrue(a.core.inventory.getItem(1) == null || a.core.inventory.getItem(1).isEmpty(),
|
||||
"Exit restores the ordinary item that occupied the basic-stick slot");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MutableClock extends Clock {
|
||||
private Instant now;
|
||||
MutableClock(Instant now) { this.now = now; }
|
||||
void advance(java.time.Duration duration) { now = now.plus(duration); }
|
||||
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
|
||||
@Override public Clock withZone(ZoneId zone) { return this; }
|
||||
@Override public Instant instant() { return now; }
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,103 @@ class CharacterBookNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void bookPurchasePersistsConAndPointsAndAppliesTheAcknowledgedNativeHealthGain() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.player.setHealth(5);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
var purchase = PartyBookNativeTest.button(a, "[+1 CON]");
|
||||
PartyBookNativeTest.click(f, a, purchase);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.CON));
|
||||
assertEquals(java.math.BigInteger.valueOf(39), saved.unspentPoints());
|
||||
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(6)));
|
||||
assertEquals(11, a.maximumHealth.getValue());
|
||||
assertEquals(6, a.player.getHealth());
|
||||
assertTrue(PartyBookNativeTest.text(a).contains("CON: 1"));
|
||||
assertTrue(PartyBookNativeTest.text(a).contains("Unspent points: 39"));
|
||||
PartyBookNativeTest.click(f, a, purchase);
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(saved, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(),
|
||||
"A rendered purchase action is single-use");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void conPurchaseKeepsItsOwnMaximumAndOneTimeGainDespiteExternalModifiers() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player);
|
||||
var before = new net.minecraft.world.entity.ai.attributes.AttributeModifier(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath("test", "before_purchase"), 5,
|
||||
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
|
||||
a.maximumHealth.addTransientModifier(before);
|
||||
f.activate(a); assertEquals(10, a.maximumHealth.getValue());
|
||||
var during = new net.minecraft.world.entity.ai.attributes.AttributeModifier(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath("test", "during_purchase"), 100,
|
||||
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
|
||||
a.maximumHealth.addPermanentModifier(during);
|
||||
assertEquals(10, a.maximumHealth.getValue());
|
||||
a.player.setHealth(5);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 CON]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(11, a.maximumHealth.getValue()); assertEquals(6, a.player.getHealth());
|
||||
assertEquals(java.util.Set.of(before, during), a.maximumHealth.getModifiers());
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(java.util.Set.of(before, during), a.maximumHealth.getModifiers());
|
||||
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.CON));
|
||||
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(6)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedPurchaseChangesNothingAndAcknowledgedStatsSurviveReopenTransferAndReload() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a);
|
||||
a.player.setHealth(5);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
var rejected = PartyBookNativeTest.button(a, "[+1 STR]");
|
||||
var before = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"), java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
PartyBookNativeTest.click(f, a, rejected);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
}
|
||||
assertEquals(before, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
assertEquals(5, a.player.getHealth()); assertEquals(10, a.maximumHealth.getValue());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
|
||||
PartyBookNativeTest.click(f, a, rejected); PartyBookNativeTest.drain(f);
|
||||
assertEquals(before, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
|
||||
PartyBookNativeTest.open(f, a);
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 STR]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.STR));
|
||||
assertEquals(java.math.BigInteger.valueOf(39), saved.unspentPoints());
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
|
||||
PartyBookNativeTest.open(f, b);
|
||||
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
|
||||
assertTrue(PartyBookNativeTest.text(b).contains("Unspent points: 39"));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
f.players.clear(); f.plugin.setEnabled(true); f.players.put(b.core.id, b.player); f.reenter(b);
|
||||
PartyBookNativeTest.open(f, b);
|
||||
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
|
||||
assertEquals(saved.stats(), characters(f).find(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow().stats());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativePagesKeepLargeNumbersUnicodeNamesAndUnknownAbilitiesReadableAndComplete() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -166,7 +263,7 @@ class CharacterBookNativeTest {
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a);
|
||||
click(f, a); drainReads(f); // Old holder has a queued result, not a delivered book.
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
var renamed = characters(f).renameCharacter(a.core.record.id(), "Traded 雪 character").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
runQueued(f); assertTrue(a.books.isEmpty());
|
||||
b.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamed));
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CombatExperienceNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void packagedBasicAttackToBossDeathPersistsLevelAndUpdatesBookFeedbackExactlyOnce() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
assertTrue(f.order.contains("CombatExperience"));
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var world = mock(org.bukkit.World.class); var worldId = UUID.randomUUID(); when(world.getUID()).thenReturn(worldId);
|
||||
var target = mob(EntityType.WARDEN, new Location(world, 10, 65, -4));
|
||||
doAnswer(call -> {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getCausingEntity()).thenReturn(a.player);
|
||||
when(source.getDirectEntity()).thenReturn(a.player);
|
||||
var event = new EntityDamageByEntityEvent(a.player, target, DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0));
|
||||
f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(anyDouble(), same(a.player));
|
||||
|
||||
a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(attack);
|
||||
assertTrue(attack.isCancelled());
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
var death = new org.bukkit.event.entity.EntityDeathEvent(target, source, new ArrayList<>(), 17);
|
||||
f.dispatcher.accept(death);
|
||||
PartyBookNativeTest.drain(f);
|
||||
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var saved = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
PartyBookNativeTest.drain(f); // Deliver the post-commit main-thread acknowledgment and feedback.
|
||||
assertEquals(java.math.BigInteger.valueOf(100), saved.experience());
|
||||
assertEquals(2, saved.level());
|
||||
assertEquals(java.math.BigInteger.valueOf(41), saved.unspentPoints());
|
||||
assertEquals(17, death.getDroppedExp(), "Fantasy rewards never replace native Minecraft XP");
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("100") && message.contains("XP")));
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Level 2")));
|
||||
assertTrue(mockingDetails(a.world).getInvocations().stream().anyMatch(invocation ->
|
||||
invocation.getMethod().getName().equals("spawnParticle")
|
||||
&& java.util.Arrays.asList(invocation.getArguments()).contains(12)));
|
||||
assertFalse(a.sounds.isEmpty());
|
||||
|
||||
a.core.inventory.getInventory().setSelectedSlot(0);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
String book = PartyBookNativeTest.text(a);
|
||||
assertTrue(book.contains("Level: 2")); assertTrue(book.contains("XP: 0/100"));
|
||||
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, source, new ArrayList<>(), 17));
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(saved, storage.find(a.core.record.id()).get().orElseThrow(), "One defeated target awards only once");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sharingProjectileExitPassiveExemptionAndPerCharacterRestrictedSpacingUseNativeEvents() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var c = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.players.put(c.core.id, c.player);
|
||||
f.activate(a); f.activate(b); f.activate(c);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
|
||||
var first = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
|
||||
hit(f, a.player, first, false);
|
||||
die(f, first); settle(f);
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, a).experience());
|
||||
assertEquals(java.math.BigInteger.ZERO, record(f, b).experience());
|
||||
|
||||
var near = mob(EntityType.BEE, new Location(world, 2, 64, 0)); // Neutral, regardless of aggression.
|
||||
hit(f, a.player, near, false);
|
||||
var arrow = PartyCombatNativeTest.projectile(f, b.player);
|
||||
hit(f, arrow, near, false);
|
||||
var ordinary = PartyCombatNativeTest.entity(org.bukkit.entity.Arrow.class);
|
||||
hit(f, ordinary, near, false); // Ordinary sources never enter the fantasy denominator.
|
||||
PartyCombatNativeTest.exit(f, b); // The arrow's originating character still owns its accepted contribution.
|
||||
die(f, near); settle(f);
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Blocked share is lost rather than redistributed");
|
||||
assertEquals(java.math.BigInteger.valueOf(5), record(f, b).experience(), "First restricted kill is eligible for this character");
|
||||
|
||||
var passive = mob(EntityType.COW, new Location(world, 2, 64, 0));
|
||||
hit(f, a.player, passive, false);
|
||||
hit(f, c.player, passive, false);
|
||||
// b is now ordinary; its direct damage cannot enter the fantasy denominator.
|
||||
hit(f, b.player, passive, false);
|
||||
die(f, passive); settle(f);
|
||||
assertEquals(java.math.BigInteger.valueOf(11), record(f, a).experience());
|
||||
assertEquals(java.math.BigInteger.valueOf(5), record(f, b).experience());
|
||||
assertEquals(java.math.BigInteger.ONE, record(f, c).experience(),
|
||||
"A one-XP passive pool rounds up to one for every fantasy contributor");
|
||||
|
||||
var exact = mob(EntityType.ZOMBIE, new Location(world, 5, 64, 0));
|
||||
hit(f, a.player, exact, false);
|
||||
hit(f, a.player, exact, true); // Cancelled repeats do not create another contributor.
|
||||
die(f, exact); settle(f);
|
||||
assertEquals(java.math.BigInteger.valueOf(21), record(f, a).experience(),
|
||||
"Passive kill did not reset history; exact three-block distance from blocked kill is eligible");
|
||||
assertEquals(new java.math.BigDecimal("5.0"), record(f, a).restrictedKills().values().iterator().next().x());
|
||||
|
||||
var repeatedPassive = mob(EntityType.SHEEP, new Location(world, 2, 64, 0));
|
||||
hit(f, a.player, repeatedPassive, false); die(f, repeatedPassive); settle(f);
|
||||
assertEquals(java.math.BigInteger.valueOf(22), record(f, a).experience(), "Same-location passive kills remain eligible");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedRewardSaveRetainsOneRetryableOperationWithoutReplayingTheTarget() {
|
||||
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
|
||||
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
var progression = games.dmg.fantasystick.progression.ProgressionRules.defaults();
|
||||
var original = new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);
|
||||
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), original.id());
|
||||
var attacker = mock(org.bukkit.entity.Player.class);
|
||||
when(attacker.getUniqueId()).thenReturn(active.player());
|
||||
when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active));
|
||||
when(sessions.current(active)).thenReturn(true);
|
||||
when(storage.find(original.id())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(java.util.Optional.of(original)));
|
||||
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.CombatAward>();
|
||||
failed.completeExceptionally(new java.io.IOException("simulated write failure"));
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), original.name(), java.math.BigInteger.ONE,
|
||||
original.stats(), original.level(), java.math.BigInteger.TEN, original.unspentPoints(), original.health(),
|
||||
original.learned(), original.loadout(), original.cooldowns(), original.restrictedKills(), false, null, null);
|
||||
var success = java.util.concurrent.CompletableFuture.completedFuture(
|
||||
new games.dmg.fantasystick.persistence.CharacterStorage.CombatAward(
|
||||
games.dmg.fantasystick.persistence.CharacterStorage.CombatAwardStatus.APPLIED, java.util.Optional.of(saved), 0));
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
when(storage.awardCombatExperience(eq(original.id()), any(), eq(java.math.BigInteger.TEN), same(progression), any(), any()))
|
||||
.thenAnswer(ignored -> writes.getAndIncrement() == 0 ? failed : success);
|
||||
var failures = new ArrayList<games.dmg.fantasystick.party.CombatExperience.Failure>();
|
||||
var service = new games.dmg.fantasystick.party.CombatExperience(storage, progression,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), sessions,
|
||||
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC), Runnable::run,
|
||||
ignored -> null, failures::add);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var target = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
|
||||
var damageSource = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(damageSource.getDirectEntity()).thenReturn(attacker); when(damageSource.getCausingEntity()).thenReturn(attacker);
|
||||
service.damage(new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, damageSource, 1));
|
||||
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
|
||||
verify(storage, times(1)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
|
||||
assertEquals(1, failures.size());
|
||||
service.retryPending();
|
||||
verify(storage, times(2)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
|
||||
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
|
||||
service.retryPending();
|
||||
verify(storage, times(2)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
|
||||
service.close();
|
||||
}
|
||||
|
||||
@Test void validatedConfiguredPoolAndZeroSpacingReachTheRegisteredRewardPath() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"xp-per-level: 10\nordinary-xp: 25\nrestricted-kill-spacing-blocks: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
for (int index = 0; index < 2; index++) {
|
||||
var target = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
|
||||
hit(f, a.player, target, false); die(f, target); settle(f);
|
||||
}
|
||||
assertEquals(java.math.BigInteger.valueOf(50), record(f, a).experience());
|
||||
assertEquals(6, record(f, a).level());
|
||||
assertEquals(java.math.BigInteger.valueOf(45), record(f, a).unspentPoints());
|
||||
assertEquals(5, a.sounds.size(), "Every gained level has its own feedback sound");
|
||||
for (int level = 2; level <= 6; level++) {
|
||||
int expected = level;
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Level " + expected)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void interceptedFantasyDefeatUsesVictimLevelPoolAndCancelledDamageCreatesNoCredit() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b);
|
||||
hit(f, a.player, b.player, true);
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
var cancelled = new org.bukkit.event.entity.PlayerDeathEvent(b.player, source, new ArrayList<>(), 0,
|
||||
net.kyori.adventure.text.Component.text("defeat"), true);
|
||||
f.dispatcher.accept(cancelled); settle(f);
|
||||
assertTrue(cancelled.isCancelled());
|
||||
assertEquals(java.math.BigInteger.ZERO, record(f, a).experience(), "Cancelled damage never contributes");
|
||||
}
|
||||
|
||||
var second = data.resolve("accepted"); java.nio.file.Files.createDirectories(second);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(second)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(second);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(second);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b);
|
||||
hit(f, a.player, b.player, false);
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
var defeat = new org.bukkit.event.entity.PlayerDeathEvent(b.player, source, new ArrayList<>(), 0,
|
||||
net.kyori.adventure.text.Component.text("defeat"), true);
|
||||
f.dispatcher.accept(defeat); settle(f); settle(f);
|
||||
assertTrue(defeat.isCancelled());
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Level-one fantasy defeat supplies a ten-XP pool");
|
||||
assertTrue(record(f, b).dead());
|
||||
f.dispatcher.accept(defeat); settle(f);
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Repeated intercepted callback cannot award twice");
|
||||
}
|
||||
}
|
||||
|
||||
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Entity source,
|
||||
org.bukkit.entity.Entity target, boolean cancelled) {
|
||||
var damage = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(damage.getCausingEntity()).thenReturn(source);
|
||||
when(damage.getDirectEntity()).thenReturn(source);
|
||||
var event = new EntityDamageByEntityEvent(source, target, DamageCause.ENTITY_ATTACK, damage, 4);
|
||||
event.setCancelled(cancelled); f.dispatcher.accept(event); return event;
|
||||
}
|
||||
|
||||
private static void die(FantasyStickLifecycleNativeTest.Fixture f, LivingEntity target) {
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target,
|
||||
mock(org.bukkit.damage.DamageSource.class), new ArrayList<>(), 0));
|
||||
}
|
||||
|
||||
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
}
|
||||
|
||||
private static games.dmg.fantasystick.persistence.CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
return storage.find(actor.core.record.id()).get().orElseThrow();
|
||||
}
|
||||
|
||||
private static LivingEntity mob(EntityType type, Location location) {
|
||||
var entity = mock(LivingEntity.class);
|
||||
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(entity.getType()).thenReturn(type);
|
||||
when(entity.getLocation()).thenAnswer(ignored -> location.clone());
|
||||
when(entity.getWorld()).thenReturn(location.getWorld());
|
||||
when(entity.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,19 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
|
||||
assertNotNull(fixture.packetListener);
|
||||
assertEquals(3, fixture.timers.size(), "Presentation, combat-provenance cleanup and session particles");
|
||||
assertEquals(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);
|
||||
assertTrue(fixture.order.indexOf("NutritionPolicy") < recipe);
|
||||
assertNotNull(fixture.command);
|
||||
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
|
||||
assertTrue(fixture.tasks.isEmpty());
|
||||
@@ -128,7 +138,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
|
||||
@Test
|
||||
void recipeCollisionAndPartialListenerRegistrationFailureRollbackOnlyThisEnableAttempt() throws Exception {
|
||||
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "presentation-timer")) {
|
||||
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "Fireball", "presentation-timer")) {
|
||||
boolean collision = stage.equals("collision");
|
||||
try (var fixture = new Fixture(data)) {
|
||||
var existing = new org.bukkit.inventory.ShapelessRecipe(CharacterSheets.RECIPE,
|
||||
@@ -206,6 +216,21 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidExperienceThresholdAndMaximumLevelFailBeforeInstallingGameplay() throws Exception {
|
||||
for (String configuration : List.of("xp-per-level: 0\n", "xp-per-level: -1\n",
|
||||
"maximum-level: 0\n", "maximum-level: 2147483648\n", "maximum-level: not-a-number\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(configuration.hashCode()));
|
||||
java.nio.file.Files.createDirectories(directory);
|
||||
java.nio.file.Files.writeString(directory.resolve("config.yml"), configuration);
|
||||
try (var fixture = new Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true));
|
||||
assertTrue(fixture.order.isEmpty());
|
||||
assertTrue(fixture.recipes.isEmpty());
|
||||
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void installedGuardRejectsOnlyTheCharacterRecipeThroughTheNativeDispatcher() throws Exception {
|
||||
try (var fixture = new Fixture(data)) {
|
||||
@@ -721,6 +746,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertNull(saved.pending()); assertNull(saved.lastExit());
|
||||
} else {
|
||||
f.activate(a); sessions.exit(a.player);
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
|
||||
var saved = f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow();
|
||||
assertNotNull(saved.pending()); assertNotNull(saved.lastExit());
|
||||
@@ -790,7 +816,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
|
||||
sessions.exit(a.player);
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get();
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
var b = new Actor(data); f.players.put(b.core.id, b.player);
|
||||
b.core.gameMode = org.bukkit.GameMode.SURVIVAL;
|
||||
f.activate(b);
|
||||
@@ -807,27 +833,74 @@ class FantasyStickLifecycleNativeTest {
|
||||
if (!value.equals("default")) java.nio.file.Files.writeString(directory.resolve("config.yml"), "character-swap-cooldown-seconds: " + value + "\n");
|
||||
try (var f = new Fixture(directory)) {
|
||||
f.plugin.setEnabled(true); var a = new Actor(directory); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
var exit = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
assertEquals(java.time.Duration.ofSeconds(value.equals("default") ? 900 : Long.parseLong(value)),
|
||||
java.time.Duration.between(exit.exitedAt(), exit.deadline()));
|
||||
assertEquals(a.core.record.id(), exit.characterId());
|
||||
var other = new Actor(directory);
|
||||
a.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand().clone());
|
||||
var before = a.core.snapshot();
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
f.tick(); f.tick(); // Join receipt reconciliation, then actual coordinator eligibility.
|
||||
if (value.equals("0")) {
|
||||
f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"), "Configured zero reaches entry, not only persisted arithmetic");
|
||||
} else {
|
||||
assertArrayEquals(before, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
|
||||
assertTrue(a.messages.getLast().matches("(?s).*Wait [1-9][0-9]* seconds.*same character may be resumed now.*"), a.messages::toString);
|
||||
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
|
||||
assertNull(state.pending()); assertEquals(exit, state.lastExit());
|
||||
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
|
||||
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
|
||||
var worker = (java.util.concurrent.ExecutorService) Fixture.field(characters, "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
worker.execute(() -> {
|
||||
entered.countDown();
|
||||
try { release.await(); }
|
||||
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
|
||||
});
|
||||
try {
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending(),
|
||||
"Quit restores native state but must retain ownership until health persistence completes");
|
||||
release.countDown();
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
var exit = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
assertEquals(java.time.Duration.ofSeconds(value.equals("default") ? 900 : Long.parseLong(value)),
|
||||
java.time.Duration.between(exit.exitedAt(), exit.deadline()));
|
||||
assertEquals(a.core.record.id(), exit.characterId());
|
||||
var other = new Actor(directory);
|
||||
a.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand().clone());
|
||||
var before = a.core.snapshot();
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
|
||||
}
|
||||
f.tick(); f.tick(); // Join receipt reconciliation, then actual coordinator eligibility.
|
||||
if (value.equals("0")) {
|
||||
f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"), "Configured zero reaches entry, not only persisted arithmetic");
|
||||
} else {
|
||||
assertArrayEquals(before, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
|
||||
assertTrue(a.messages.getLast().matches("(?s).*Wait [1-9][0-9]* seconds.*same character may be resumed now.*"), a.messages::toString);
|
||||
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
|
||||
assertNull(state.pending()); assertEquals(exit, state.lastExit());
|
||||
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
|
||||
}
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void 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",
|
||||
"restricted-kill-spacing-blocks: -0.1", "restricted-kill-spacing-blocks: 1e1000000")) {
|
||||
var directory = data.resolve("invalid-reward-" + scenario++); java.nio.file.Files.createDirectories(directory);
|
||||
String config = row + "\n"; java.nio.file.Files.writeString(directory.resolve("config.yml"), config);
|
||||
try (var f = new Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true), row);
|
||||
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty()); assertTrue(f.tasks.isEmpty());
|
||||
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
|
||||
assertEquals(config, java.nio.file.Files.readString(directory.resolve("config.yml")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1028,6 +1101,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
};
|
||||
final List<BukkitTask> timers = new ArrayList<>();
|
||||
final List<Runnable> pulses = new ArrayList<>();
|
||||
final List<Runnable> delayed = new ArrayList<>();
|
||||
final List<Long> delayedTicks = new ArrayList<>();
|
||||
com.comphenix.protocol.events.PacketListener packetListener;
|
||||
final List<java.util.concurrent.ExecutorService> workers = new ArrayList<>();
|
||||
final Thread main = Thread.currentThread();
|
||||
@@ -1096,6 +1171,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]); delayedTicks.add((Long) args[2]);
|
||||
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) {
|
||||
@@ -1107,6 +1188,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "getOnlinePlayers" -> List.copyOf(players.values());
|
||||
case "getPlayer" -> { assertSame(main, Thread.currentThread()); playerLookups++; yield players.get(args[0]); }
|
||||
case "getPluginCommand" -> command;
|
||||
case "createInventory" -> new org.bukkit.craftbukkit.inventory.CraftInventoryCustom(
|
||||
(org.bukkit.inventory.InventoryHolder) args[0], (Integer) args[1], (net.kyori.adventure.text.Component) args[2]);
|
||||
case "addRecipe" -> {
|
||||
order.add("recipe");
|
||||
if ("recipe".equals(failRegistrationAt)) throw new IllegalStateException("Recipe registration failed");
|
||||
@@ -1144,6 +1227,12 @@ class FantasyStickLifecycleNativeTest {
|
||||
games.dmg.fantasystick.persistence.EquipmentSessionStore receipts() {
|
||||
return (games.dmg.fantasystick.persistence.EquipmentSessionStore) field(plugin, "receipts");
|
||||
}
|
||||
void awaitCharacterAndReceipt(Actor actor) throws Exception {
|
||||
// A receipt read alone cannot await work submitted later by the character-save callback.
|
||||
((CharacterStorage) field(plugin, "characters")).find(actor.core.record.id())
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
receipts().find(actor.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
}
|
||||
void tick() throws Exception {
|
||||
var task = tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(task, "Missing deferred main-thread callback"); task.run();
|
||||
@@ -1176,7 +1265,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(actor.core.marker().endsWith("|PACKED"));
|
||||
}
|
||||
private void captureWorkers(JavaPlugin instance) {
|
||||
for (String name : List.of("characters", "receipts", "adventureSettings")) {
|
||||
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals")) {
|
||||
var store = field(instance, name);
|
||||
if (store != null) {
|
||||
var worker = (java.util.concurrent.ExecutorService) field(store, "worker");
|
||||
@@ -1211,7 +1300,10 @@ class FantasyStickLifecycleNativeTest {
|
||||
final org.bukkit.World world = mock(org.bukkit.World.class);
|
||||
final List<String> messages = new ArrayList<>();
|
||||
final List<org.bukkit.inventory.ItemStack> books = new ArrayList<>();
|
||||
final List<org.bukkit.inventory.Inventory> inventories = new ArrayList<>();
|
||||
int inventoryCloses;
|
||||
final List<Integer> particles = new ArrayList<>();
|
||||
final List<org.bukkit.Sound> sounds = new ArrayList<>();
|
||||
final net.minecraft.world.entity.ai.attributes.AttributeMap healthAttributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(
|
||||
net.minecraft.world.entity.ai.attributes.AttributeSupplier.builder().add(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).build());
|
||||
final net.minecraft.world.entity.ai.attributes.AttributeInstance maximumHealth = java.util.Objects.requireNonNull(
|
||||
@@ -1220,6 +1312,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
boolean healthScaled;
|
||||
double healthScale = 20;
|
||||
Runnable scaleMutation = () -> {};
|
||||
Runnable foodMutation = () -> {};
|
||||
java.util.function.Consumer<org.bukkit.event.player.PlayerGameModeChangeEvent> modeEvents = ignored -> { };
|
||||
final org.bukkit.craftbukkit.entity.CraftPlayer player;
|
||||
final net.minecraft.server.level.ServerPlayer nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
@@ -1253,7 +1346,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
case "getMaxHealth" -> maximumHealth.getValue();
|
||||
case "getFoodLevel" -> food.getFoodLevel();
|
||||
case "setFoodLevel" -> { food.setFoodLevel((Integer) args[0]); yield null; }
|
||||
case "setFoodLevel" -> { food.setFoodLevel((Integer) args[0]); foodMutation.run(); yield null; }
|
||||
case "getSaturation" -> food.getSaturationLevel();
|
||||
case "setSaturation" -> { food.setSaturation((Float) args[0]); yield null; }
|
||||
case "getExhaustion" -> food.exhaustionLevel;
|
||||
@@ -1273,7 +1366,10 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "getScoreboard" -> craftBoard;
|
||||
case "sendMessage" -> { messages.add((String) args[0]); yield null; }
|
||||
case "openBook" -> { books.add(((org.bukkit.inventory.ItemStack) args[0]).clone()); yield null; }
|
||||
case "openInventory" -> { inventories.add((org.bukkit.inventory.Inventory) args[0]); yield core.view; }
|
||||
case "closeInventory" -> { inventoryCloses++; yield null; }
|
||||
case "spawnParticle" -> { particles.add((Integer) args[2]); yield null; }
|
||||
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
|
||||
default -> Proxy.getInvocationHandler(core.player).invoke(core.player, method, args);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
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 bookUnlearnRefundsOnceClearsOwnedFireballAndRetainsDeadlineThroughReuse() 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);
|
||||
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");
|
||||
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var projectile = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
|
||||
var launches = new java.util.concurrent.atomic.AtomicInteger();
|
||||
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();
|
||||
org.mockito.Mockito.doAnswer(call -> { launches.incrementAndGet(); return projectile; }).when(a.player)
|
||||
.launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
|
||||
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
|
||||
cast(f, a);
|
||||
var cast = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
var deadline = cast.cooldowns().get("fireball"); assertNotNull(deadline); assertEquals(1, launches.get());
|
||||
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
for (var page : content.pages()) for (String line : page.raw().getString().split("\\n", -1)) {
|
||||
assertTrue(line.codePoints().map(cp -> cp < 128 ? 6 : 9).sum() <= 108,
|
||||
"Learned ability action exceeds native visible width: " + line);
|
||||
}
|
||||
var unlearn = PartyBookNativeTest.button(a, "[Unlearn]");
|
||||
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
|
||||
assertFalse(refunded.learned().contains("fireball"));
|
||||
assertTrue(refunded.loadout().stream().noneMatch("fireball"::equals));
|
||||
assertEquals(deadline, refunded.cooldowns().get("fireball"));
|
||||
org.mockito.Mockito.verify(projectile, org.mockito.Mockito.atLeastOnce()).remove();
|
||||
assertTrue(new games.dmg.fantasystick.items.SessionControls(
|
||||
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
|
||||
.ability(a.core.inventory.getItem(2)).isEmpty());
|
||||
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 WIS]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var spent = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.ONE, spent.stats().get(games.dmg.fantasystick.progression.Stat.WIS));
|
||||
assertEquals(BigInteger.valueOf(39), spent.unspentPoints());
|
||||
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);
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
|
||||
cast(f, a); assertEquals(1, launches.get());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
|
||||
var transferredSheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(transferredSheet);
|
||||
f.players.put(b.core.id, b.player); f.activate(b);
|
||||
b.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(b.core.inventory.getItem(2)).orElseThrow());
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(b.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
b.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")),
|
||||
"Transfer/reload must retain the original Fireball deadline after refund and relearn");
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
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 FrostShardNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedUseAndDirectHitDealDamageAndOwnedSlow() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withStats(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(book.contains("FrostShard"), book); assertTrue(book.contains("INT12"), book);
|
||||
assertTrue(book.contains("WIS10"), book); assertTrue(book.contains("SlownessI"), book);
|
||||
var learn = abilityButton(a, "[Learn]", "frost_shard");
|
||||
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "frost_shard"));
|
||||
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();
|
||||
assertEquals(BigInteger.valueOf(39), assigned.unspentPoints());
|
||||
assertTrue(assigned.learned().contains("frost_shard"));
|
||||
assertEquals("frost_shard", assigned.loadout().getFirst());
|
||||
assertEquals(org.bukkit.Material.PRISMARINE_SHARD, a.core.inventory.getItem(2).getType());
|
||||
|
||||
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));
|
||||
doReturn(eye).when(a.player).getEyeLocation();
|
||||
var projectile = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
|
||||
doAnswer(call -> {
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
|
||||
return projectile;
|
||||
}).when(a.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), 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());
|
||||
verify(a.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
|
||||
var frost = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "frostShard");
|
||||
assertEquals(1, ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(frost, "projectiles")).size());
|
||||
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 10, 65.6, 0));
|
||||
when(target.addPotionEffect(any())).thenReturn(true);
|
||||
var damages = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
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(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
var hit = new org.bukkit.event.entity.ProjectileHitEvent(projectile, target);
|
||||
f.dispatcher.accept(hit);
|
||||
assertFalse(hit.isCancelled(), "Eligible hostile Frost Shard hit was unexpectedly cancelled");
|
||||
assertEquals(1, damages.size()); assertEquals(5.0, damages.getFirst().getDamage());
|
||||
var effect = org.mockito.ArgumentCaptor.forClass(org.bukkit.potion.PotionEffect.class);
|
||||
verify(target).addPotionEffect(effect.capture());
|
||||
assertEquals(org.bukkit.potion.PotionEffectType.SLOWNESS, effect.getValue().getType());
|
||||
assertEquals(40, effect.getValue().getDuration()); assertEquals(0, effect.getValue().getAmplifier());
|
||||
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(),
|
||||
"Accepted Frost Shard damage is attributed to the originating character");
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("frost_shard"));
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, target));
|
||||
assertEquals(1, damages.size());
|
||||
verify(projectile, atLeastOnce()).remove();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partyMixedModeAndCancelledDamageApplyNeitherDamageNorSlow() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-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);
|
||||
withFrostShard(data, a, 12, 10);
|
||||
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()));
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var first = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
var second = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
var third = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
prepareLaunches(f, a, first, second, third);
|
||||
|
||||
cast(f, a); var partyHit = new org.bukkit.event.entity.ProjectileHitEvent(first, b.player); f.dispatcher.accept(partyHit);
|
||||
assertTrue(partyHit.isCancelled()); verify(b.player, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
var ordinary = mock(org.bukkit.entity.Player.class); when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
cast(f, a); var ordinaryHit = new org.bukkit.event.entity.ProjectileHitEvent(second, ordinary); f.dispatcher.accept(ordinaryHit);
|
||||
assertTrue(ordinaryHit.isCancelled()); verify(ordinary, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(third, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0));
|
||||
event.setCancelled(true); f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(third, target));
|
||||
verify(target, never()).addPotionEffect(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rangeLifetimeAndSessionEndRemoveOwnedProjectilesWithoutLateDamage() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); 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 = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.addPotionEffect(any())).thenReturn(true);
|
||||
var damages = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doAnswer(call -> {
|
||||
damages.incrementAndGet();
|
||||
var source = call.getArgument(1, org.bukkit.damage.DamageSource.class);
|
||||
var projectile = source.getDirectEntity();
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0)));
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(outside, target));
|
||||
assertEquals(0, damages.get()); verify(outside, atLeastOnce()).remove();
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(boundary, target));
|
||||
assertEquals(1, damages.get());
|
||||
cast(f, a); f.delayed.getLast().run(); verify(expiring, atLeastOnce()).remove();
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(expiring, target)); assertEquals(1, damages.get());
|
||||
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();
|
||||
verify(exiting, atLeastOnce()).remove();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOwnedProjectile() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 60\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); learnAndAssign(f, a);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var projectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0)); prepareLaunches(f, a, projectile);
|
||||
cast(f, a);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("frost_shard");
|
||||
assertNotNull(deadline);
|
||||
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
var unlearn = abilityButton(a, "[Unlearn]", "frost_shard");
|
||||
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
|
||||
assertFalse(refunded.learned().contains("frost_shard"));
|
||||
assertTrue(refunded.loadout().stream().noneMatch("frost_shard"::equals));
|
||||
assertEquals(deadline, refunded.cooldowns().get("frost_shard"));
|
||||
verify(projectile, atLeastOnce()).remove();
|
||||
|
||||
learnAndAssign(f, a); cast(f, a);
|
||||
verify(a.player, times(1)).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
|
||||
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);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
|
||||
f.players.put(b.core.id, b.player); f.activate(b); cast(f, b);
|
||||
verify(b.player, never()).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("frost_shard"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceLaunchesNoProjectileOrSlow() {
|
||||
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
|
||||
var sessions = 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 frost = new games.dmg.fantasystick.ability.FrostShard(
|
||||
games.dmg.fantasystick.ability.FrostShard.Settings.defaults()); registry.register(frost.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));
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.WIS, BigInteger.TEN);
|
||||
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("frost_shard"),
|
||||
java.util.List.of("frost_shard", "", "", "", "", "", ""), 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 = mock(org.bukkit.entity.Player.class);
|
||||
when(sessions.current(active)).thenReturn(true); 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 Frost Shard cooldown failure"));
|
||||
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("frost_shard"), 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, "frost_shard",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
|
||||
verify(player, never()).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
|
||||
assertEquals(1, failures.size()); engine.close(); frost.close(); parties.close();
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAndFormulaPreserveReviewedFiniteUncappedScaling() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"frost-shard-base-damage: 9\nfrost-shard-cooldown-seconds: 2.5\n"
|
||||
+ "frost-shard-range-blocks: 40\nfrost-shard-lifetime-seconds: 7\n"
|
||||
+ "frost-shard-projectile-speed-blocks-per-tick: 2\n"
|
||||
+ "frost-shard-slowness-amplifier: 2\nfrost-shard-slowness-duration-seconds: 3\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("frost_shard").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofMillis(2500), 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(0, new java.math.BigDecimal("7").compareTo(definition.parameters().get("lifetime")));
|
||||
assertEquals(0, new java.math.BigDecimal("2").compareTo(definition.parameters().get("projectile-speed")));
|
||||
assertEquals(0, new java.math.BigDecimal("2").compareTo(definition.parameters().get("slowness-amplifier")));
|
||||
assertEquals(0, new java.math.BigDecimal("3").compareTo(definition.parameters().get("slowness-duration")));
|
||||
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);
|
||||
assertTrue(PartyBookNativeTest.text(unavailable).replaceAll("\\s+", "")
|
||||
.contains("Unavailable:requirements"));
|
||||
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "frost_shard"));
|
||||
}
|
||||
assertEquals(BigInteger.valueOf(4), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.TEN));
|
||||
assertEquals(BigInteger.valueOf(5), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)),
|
||||
games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.TEN.pow(500)));
|
||||
}
|
||||
|
||||
@Test void invalidPackagedFrostSettingsFailBeforeInstallingGameplay() throws Exception {
|
||||
for (String configuration : java.util.List.of("frost-shard-base-damage: -1\n",
|
||||
"frost-shard-cooldown-seconds: 31536001\n", "frost-shard-range-blocks: 0\n",
|
||||
"frost-shard-lifetime-seconds: 0\n", "frost-shard-projectile-speed-blocks-per-tick: 0\n",
|
||||
"frost-shard-slowness-amplifier: 256\n", "frost-shard-slowness-duration-seconds: 0\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(configuration.hashCode()));
|
||||
java.nio.file.Files.createDirectories(directory); java.nio.file.Files.writeString(directory.resolve("config.yml"), configuration);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
|
||||
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty());
|
||||
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sourceExitRemovesOnlyOwnedSlowAndRestoresThePriorNativeLayer() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
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));
|
||||
doReturn(eye).when(a.player).getEyeLocation();
|
||||
var projectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile)); return projectile; })
|
||||
.when(a.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
var previous = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS, 200, 1);
|
||||
var current = new java.util.concurrent.atomic.AtomicReference<org.bukkit.potion.PotionEffect>(previous);
|
||||
when(target.getPotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS)).thenAnswer(call -> current.get());
|
||||
when(target.addPotionEffect(any())).thenAnswer(call -> { current.set(call.getArgument(0)); return true; });
|
||||
doAnswer(call -> { current.set(null); return null; }).when(target)
|
||||
.removePotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS);
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
|
||||
(Double) call.getArgument(0))); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
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); PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, target));
|
||||
assertEquals(0, current.get().getAmplifier());
|
||||
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();
|
||||
assertNotNull(current.get()); assertEquals(1, current.get().getAmplifier());
|
||||
assertTrue(current.get().getDuration() > 0 && current.get().getDuration() <= 200);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reenableWaitsForAcceptedFrostConfigurationBeforeInstallingGameplay() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var settings = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "frostShardSettings");
|
||||
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(settings, "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
|
||||
worker.execute(() -> {
|
||||
entered.countDown();
|
||||
try { release.await(5, java.util.concurrent.TimeUnit.SECONDS); }
|
||||
catch (InterruptedException failure) { Thread.currentThread().interrupt(); }
|
||||
});
|
||||
try {
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage", "9"});
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
assertNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"));
|
||||
release.countDown(); f.tick();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("9"), registry.find("frost_shard").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAdminPersistenceLeavesTheEffectiveDefinitionAndExternalFileUnchanged() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var outside = data.resolveSibling(data.getFileName() + "-frost-outside");
|
||||
java.nio.file.Files.writeString(outside, "untouched\n");
|
||||
java.nio.file.Files.createSymbolicLink(data.resolve("config.yml"), outside);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage", "9"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("4"), registry.find("frost_shard").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
assertEquals("untouched\n", java.nio.file.Files.readString(outside));
|
||||
java.nio.file.Files.delete(data.resolve("config.yml")); java.nio.file.Files.delete(outside);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedUpdatesAffectFutureCastsButNotAnActiveProjectileSnapshot() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var oldProjectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
var newProjectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
prepareLaunches(f, a, oldProjectile, newProjectile); cast(f, a);
|
||||
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage", "9"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0)); when(target.addPotionEffect(any())).thenReturn(true);
|
||||
var damages = new java.util.ArrayList<Double>();
|
||||
doAnswer(call -> {
|
||||
damages.add(call.getArgument(0)); var source = call.getArgument(1, org.bukkit.damage.DamageSource.class);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(source.getDirectEntity(), target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0)));
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(oldProjectile, target));
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(newProjectile, target));
|
||||
assertEquals(java.util.List.of(5.0, 10.0), damages);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void administratorCanReadUpdateAndCompleteEveryFrostShardSetting() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(java.util.List.of("frost-shard-base-damage"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "frost-shard-b"}));
|
||||
assertEquals(java.util.List.of("frost-shard-base-damage", "frost-shard-cooldown-seconds",
|
||||
"frost-shard-lifetime-seconds", "frost-shard-projectile-speed-blocks-per-tick",
|
||||
"frost-shard-range-blocks", "frost-shard-slowness-amplifier",
|
||||
"frost-shard-slowness-duration-seconds"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "frost-shard-"}));
|
||||
assertEquals(java.util.List.of("4"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "frost-shard-base-damage", ""}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "unknown"}).isEmpty());
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "frost"}).isEmpty());
|
||||
sender.allowed = true;
|
||||
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage"});
|
||||
assertTrue(sender.messages.getLast().contains("frost-shard-base-damage=4"));
|
||||
sender.messages.clear();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage", "9"});
|
||||
assertTrue(sender.messages.isEmpty(), "Do not publish settings before durable acknowledgment");
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertTrue(sender.messages.getLast().contains("frost-shard-base-damage=9"));
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("9"), registry.find("frost_shard").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
var saved = (java.util.Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(
|
||||
java.nio.file.Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("9", String.valueOf(saved.get("frost-shard-base-damage")));
|
||||
|
||||
for (String invalid : java.util.List.of("-1", "1.5", "nope")) {
|
||||
f.command.execute(sender.port, "fantasystick",
|
||||
new String[]{"config", "frost-shard-base-damage", invalid});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
}
|
||||
assertEquals(new java.math.BigDecimal("9"), registry.find("frost_shard").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("9"), reloaded.find("frost_shard").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
sender.messages.clear();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage"});
|
||||
assertTrue(sender.messages.getLast().contains("=9"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "frost_shard"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "frost_shard"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
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 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)); doReturn(eye).when(actor.player).getEyeLocation();
|
||||
var queue = new java.util.ArrayDeque<>(java.util.List.of(projectiles));
|
||||
doAnswer(call -> {
|
||||
var projectile = queue.removeFirst(); fixture.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
|
||||
return projectile;
|
||||
}).when(actor.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
|
||||
}
|
||||
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
|
||||
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
|
||||
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(book);
|
||||
for (var page : book.pages()) {
|
||||
var found = abilityButton(page.raw(), label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
throw new AssertionError("Missing " + label + " action for " + ability);
|
||||
}
|
||||
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
|
||||
net.minecraft.network.chat.Component component, String label, String ability) {
|
||||
if (component.getString().equals(label)
|
||||
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom
|
||||
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
|
||||
&& payload.value().contains(":" + ability)) return custom;
|
||||
for (var child : component.getSiblings()) {
|
||||
var found = abilityButton(child, label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void withFrostShard(Path data, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
int intelligence, int wisdom) 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));
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.WIS, BigInteger.valueOf(wisdom));
|
||||
repository.replace(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("frost_shard"), java.util.List.of("frost_shard", "", "", "", "", "", ""),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
|
||||
private static void withStats(Path data, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
int intelligence, int wisdom) 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));
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.WIS, BigInteger.valueOf(wisdom));
|
||||
repository.replace(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), old.revision());
|
||||
}
|
||||
|
||||
private static org.bukkit.entity.Snowball projectile(FantasyStickLifecycleNativeTest.Actor actor,
|
||||
org.bukkit.Location location) {
|
||||
var projectile = mock(org.bukkit.entity.Snowball.class);
|
||||
when(projectile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(projectile.getShooter()).thenReturn(actor.player);
|
||||
when(projectile.getLocation()).thenAnswer(ignored -> location.clone());
|
||||
when(projectile.getWorld()).thenReturn(location.getWorld());
|
||||
when(projectile.getPersistentDataContainer()).thenReturn(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
return projectile;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
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 GreatSwordNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionAndFullyChargedEquippedStrikeReplaceVanillaDamage() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withStrength(data, a, 12);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
|
||||
a.core.inventory.getInventory().setSelectedSlot(0);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
String book = PartyBookNativeTest.text(a);
|
||||
assertTrue(book.contains("Great Sword"));
|
||||
assertTrue(book.contains("STR 12"));
|
||||
assertTrue(book.contains("1.2"));
|
||||
var learn = abilityButton(a, "[Learn]", "great_sword");
|
||||
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "great_sword"));
|
||||
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("great_sword"));
|
||||
assertEquals("great_sword", assigned.loadout().getFirst());
|
||||
assertEquals(BigInteger.valueOf(39), assigned.unspentPoints());
|
||||
assertEquals(org.bukkit.Material.WOODEN_SWORD, a.core.inventory.getItem(2).getType());
|
||||
|
||||
when(a.player.getAttackCooldown()).thenReturn(1.0f);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
|
||||
var damages = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
|
||||
(Double) call.getArgument(0));
|
||||
damages.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var before = java.time.Instant.now();
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(attack);
|
||||
assertTrue(attack.isCancelled(), "The bound sword must never continue into vanilla damage");
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(1, damages.size());
|
||||
assertEquals(8.0, damages.getFirst().getDamage());
|
||||
assertSame(a.player, damages.getFirst().getDamager());
|
||||
assertEquals(org.bukkit.damage.DamageType.PLAYER_ATTACK, damages.getFirst().getDamageSource().getDamageType());
|
||||
|
||||
var saved = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
var deadline = saved.cooldowns().get("great_sword");
|
||||
assertNotNull(deadline);
|
||||
assertFalse(deadline.isBefore(before.plusMillis(1200)));
|
||||
assertFalse(deadline.isAfter(java.time.Instant.now().plusMillis(1200)));
|
||||
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(),
|
||||
"Accepted direct damage is attributed to the originating character");
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(1, damages.size(), "The persisted recovery prevents a repeated strike");
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partialChargeUsesTheApprovedNativeCurveWithoutVanillaContinuation() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "great-sword-recovery-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withGreatSword(data, a, 12);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
when(a.player.getAttackCooldown()).thenReturn(0.5f);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
var damages = new java.util.ArrayList<Double>();
|
||||
doAnswer(call -> {
|
||||
double amount = call.getArgument(0); damages.add(amount);
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, amount));
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(attack); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertTrue(attack.isCancelled());
|
||||
assertEquals(java.util.List.of(3.2), damages);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void refusedPlayersAndCancelledNativeDamagePreserveProtectionWithoutFreeVanillaHits() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withGreatSword(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()));
|
||||
when(a.player.getAttackCooldown()).thenReturn(1.0f);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
|
||||
var partyAttack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true);
|
||||
f.dispatcher.accept(partyAttack); PartyBookNativeTest.drain(f);
|
||||
assertTrue(partyAttack.isCancelled());
|
||||
verify(b.player, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
|
||||
var ordinary = mock(org.bukkit.entity.Player.class);
|
||||
when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
var ordinaryAttack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, ordinary, true);
|
||||
f.dispatcher.accept(ordinaryAttack); PartyBookNativeTest.drain(f);
|
||||
assertTrue(ordinaryAttack.isCancelled());
|
||||
verify(ordinary, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
|
||||
var damage = new java.util.concurrent.atomic.AtomicReference<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
|
||||
(Double) call.getArgument(0));
|
||||
event.setCancelled(true); damage.set(event); f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertNotNull(damage.get()); assertTrue(damage.get().isCancelled());
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"),
|
||||
"An acknowledged recovery is not refunded by later native cancellation");
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damage.get().getDamageSource(),
|
||||
new java.util.ArrayList<>(), 0));
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(BigInteger.ZERO, storage.find(a.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndWholeCharacterTransferRetainTheOriginalRecovery() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "great-sword-recovery-seconds: 60\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStrength(data, a, 12);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
learnAndAssign(f, a);
|
||||
when(a.player.getAttackCooldown()).thenReturn(1.0f);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
var hits = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doAnswer(call -> {
|
||||
hits.incrementAndGet();
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
|
||||
(Double) call.getArgument(0))); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(1, hits.get());
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword");
|
||||
assertNotNull(deadline);
|
||||
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
var unlearn = abilityButton(a, "[Unlearn]", "great_sword");
|
||||
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
|
||||
assertFalse(refunded.learned().contains("great_sword"));
|
||||
assertTrue(refunded.loadout().stream().noneMatch("great_sword"::equals));
|
||||
assertEquals(deadline, refunded.cooldowns().get("great_sword"));
|
||||
assertTrue(new games.dmg.fantasystick.items.SessionControls(
|
||||
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
|
||||
.ability(a.core.inventory.getItem(2)).isEmpty());
|
||||
|
||||
learnAndAssign(f, a);
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(1, hits.get());
|
||||
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);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
|
||||
f.players.put(b.core.id, b.player); f.activate(b);
|
||||
when(b.player.getAttackCooldown()).thenReturn(1.0f);
|
||||
b.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(b.player, target, true));
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(1, hits.get(), "Transfer cannot reset the character-bound recovery");
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceDealsNoDamageOrFreeEffect() {
|
||||
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
|
||||
var sessions = 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 sword = new games.dmg.fantasystick.ability.GreatSword(
|
||||
games.dmg.fantasystick.ability.GreatSword.Settings.defaults());
|
||||
registry.register(sword.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.STR, 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("great_sword"),
|
||||
java.util.List.of("great_sword", "", "", "", "", "", ""), 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 = mock(org.bukkit.entity.Player.class);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(sessions.current(active)).thenReturn(true);
|
||||
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 Great Sword cooldown failure"));
|
||||
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("great_sword"), 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, "great_sword",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1, () -> true));
|
||||
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
assertEquals(1, failures.size());
|
||||
engine.close(); sword.close(); parties.close();
|
||||
}
|
||||
|
||||
@Test void invalidGreatSwordBalanceFailsBeforeInstallingGameplay() throws Exception {
|
||||
for (String configuration : java.util.List.of("great-sword-base-damage: -1\n",
|
||||
"great-sword-recovery-seconds: -0.1\n", "great-sword-recovery-seconds: 31536001\n",
|
||||
"great-sword-recovery-seconds: 0.0000000001\n", "great-sword-base-damage: nope\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(configuration.hashCode()));
|
||||
java.nio.file.Files.createDirectories(directory);
|
||||
java.nio.file.Files.writeString(directory.resolve("config.yml"), configuration);
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true));
|
||||
assertTrue(fixture.order.isEmpty()); assertTrue(fixture.recipes.isEmpty());
|
||||
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAndChargeCurvePreserveExactUncappedScaling() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"great-sword-base-damage: 9\ngreat-sword-recovery-seconds: 2.5\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("great_sword").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofMillis(2500), definition.cooldown());
|
||||
assertEquals(new java.math.BigDecimal("9"), definition.parameters().get("base-damage"));
|
||||
assertEquals("Great Sword", definition.name());
|
||||
assertEquals(org.bukkit.Material.WOODEN_SWORD, definition.icon());
|
||||
assertEquals(java.util.Map.of(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(12)),
|
||||
definition.requirements());
|
||||
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, definition.gesture());
|
||||
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 text = PartyBookNativeTest.text(unavailable);
|
||||
assertTrue(text.contains("Great Sword"));
|
||||
assertTrue(text.replaceAll("\\s+", "").contains("Unavailable:requirements"), text);
|
||||
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "great_sword"));
|
||||
}
|
||||
assertEquals(new java.math.BigDecimal("8"),
|
||||
games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.valueOf(12), 1.0f));
|
||||
assertEquals(new java.math.BigDecimal("3.2"),
|
||||
games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.valueOf(12), 0.5f));
|
||||
assertEquals(new java.math.BigDecimal("1.6"),
|
||||
games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.valueOf(12), 0.0f));
|
||||
assertEquals(new java.math.BigDecimal(BigInteger.TEN.pow(500).subtract(BigInteger.TEN)
|
||||
.divide(BigInteger.TWO).add(BigInteger.valueOf(7))),
|
||||
games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.TEN.pow(500), 1.0f));
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "great_sword"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "great_sword"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
|
||||
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
|
||||
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(book);
|
||||
for (var page : book.pages()) {
|
||||
var found = abilityButton(page.raw(), label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
throw new AssertionError("Missing " + label + " action for " + ability);
|
||||
}
|
||||
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
|
||||
net.minecraft.network.chat.Component component, String label, String ability) {
|
||||
if (component.getString().equals(label)
|
||||
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom
|
||||
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
|
||||
&& payload.value().contains(":" + ability)) return custom;
|
||||
for (var child : component.getSiblings()) {
|
||||
var found = abilityButton(child, label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void withGreatSword(Path data, FantasyStickLifecycleNativeTest.Actor actor, int strength) 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.STR, BigInteger.valueOf(strength));
|
||||
repository.replace(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("great_sword"), java.util.List.of("great_sword", "", "", "", "", "", ""),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
|
||||
private static void withStrength(Path data, FantasyStickLifecycleNativeTest.Actor actor, int strength) 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.STR, BigInteger.valueOf(strength));
|
||||
repository.replace(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), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.AbstractArrow;
|
||||
import org.bukkit.entity.Arrow;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.ProjectileHitEvent;
|
||||
import org.bukkit.event.entity.ProjectileLaunchEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
class LongbowNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionNativeDrawFullShotDamageAttributionAndPersistentRecovery() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 0, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("Longbow")); assertTrue(text.contains("DEX14"));
|
||||
assertTrue(text.contains("Cost:1"), text); assertTrue(text.contains("Draw"), text);
|
||||
assertTrue(text.contains("1s"), text);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "longbow"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "longbow"));
|
||||
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();
|
||||
assertTrue(learned.learned().contains("longbow")); assertEquals("longbow", learned.loadout().getFirst());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
|
||||
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var arrow = arrow(a, new Location(a.world, 6, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 6, 65.6, 0)); when(target.getWorld()).thenReturn(a.world);
|
||||
var damages = new ArrayList<EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var source = call.getArgument(1, DamageSource.class);
|
||||
var event = new EntityDamageByEntityEvent(arrow, target, EntityDamageEvent.DamageCause.PROJECTILE,
|
||||
source, (Double) call.getArgument(0)); damages.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class));
|
||||
a.core.inventory.setItem(20, new ItemStack(Material.ARROW, 32));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var velocity = ArgumentCaptor.forClass(Vector.class);
|
||||
verify(a.player).launchProjectile(eq(Arrow.class), velocity.capture());
|
||||
assertEquals(3.0, velocity.getValue().length(), 1.0e-9);
|
||||
verify(arrow).setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
|
||||
verify(arrow).setDamage(0);
|
||||
assertEquals(32, a.core.inventory.getItem(20).getAmount(), "Ordinary ammunition must remain untouched");
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
|
||||
assertEquals(1, damages.size()); assertEquals(7.0, damages.getFirst().getFinalDamage(), 1.0e-9);
|
||||
f.dispatcher.accept(new EntityDeathEvent(target, damages.getFirst().getDamageSource(), new ArrayList<>(), 0));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var saved = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.TEN, saved.experience()); assertNotNull(saved.cooldowns().get("longbow"));
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target)); assertEquals(1, damages.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partialDrawUsesReviewedCurveWhileTooShortDrawRefusesBeforeCooldown() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var arrow = arrow(a, new Location(a.world, 6, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
drawAndRelease(f, a, 2);
|
||||
verify(a.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("longbow"));
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("not eligible")));
|
||||
|
||||
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 6, 65.6, 0));
|
||||
var amount = new double[1];
|
||||
doAnswer(call -> {
|
||||
amount[0] = call.getArgument(0); var source = call.getArgument(1, DamageSource.class);
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(arrow, target, EntityDamageEvent.DamageCause.PROJECTILE,
|
||||
source, amount[0])); return null;
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class));
|
||||
drawAndRelease(f, a, 10); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
|
||||
assertEquals(7.0 * (0.25 + 1.0) / 3.0, amount[0], 1.0e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeDamagePartyTargetsAndCancelledCustomDamageProduceNoAcceptedHit() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withLongbow(data, a, 14); f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var party = PartyLifecycleNativeTest.parties(f).create(a.core.id).orElseThrow();
|
||||
assertTrue(PartyLifecycleNativeTest.parties(f).accept(b.core.id,
|
||||
PartyLifecycleNativeTest.parties(f).invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
|
||||
var arrow = arrow(a, new Location(a.world, 4, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var ordinaryTarget = mock(LivingEntity.class); when(ordinaryTarget.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
var nativeDamage = new EntityDamageByEntityEvent(arrow, ordinaryTarget,
|
||||
EntityDamageEvent.DamageCause.PROJECTILE, 7.0);
|
||||
f.dispatcher.accept(nativeDamage); assertTrue(nativeDamage.isCancelled(), "Owned arrows cannot add vanilla damage");
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, b.player));
|
||||
verify(b.player, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
|
||||
var c = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, c, 14);
|
||||
f.players.put(c.core.id, c.player); f.activate(c); when(c.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var arrow2 = arrow(c, new Location(c.world, 4, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow2)); return arrow2; })
|
||||
.when(c.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
var hostile = mock(LivingEntity.class); when(hostile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(hostile.getLocation()).thenReturn(new Location(c.world, 4, 65.6, 0));
|
||||
doAnswer(call -> {
|
||||
var source = call.getArgument(1, DamageSource.class);
|
||||
var event = new EntityDamageByEntityEvent(arrow2, hostile, EntityDamageEvent.DamageCause.PROJECTILE,
|
||||
source, (Double) call.getArgument(0)); event.setCancelled(true); f.dispatcher.accept(event); return null;
|
||||
}).when(hostile).damage(anyDouble(), any(DamageSource.class));
|
||||
drawAndRelease(f, c, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow2, hostile));
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(BigInteger.ZERO, storage.find(c.core.record.id()).get().orElseThrow().experience());
|
||||
assertNotNull(storage.find(c.core.record.id()).get().orElseThrow().cooldowns().get("longbow"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rangeLifetimeAndSessionEndRemoveOwnedArrowsWithoutLateDamage() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
for (int route = 0; route < 3; route++) {
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var location = new Location(a.world, route == 0 ? 64.01 : 2, 65.6, 0);
|
||||
var arrow = arrow(a, location);
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
if (route == 0) {
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
|
||||
verify(target, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
} else if (route == 1) {
|
||||
f.delayed.getLast().run();
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
|
||||
verify(target, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
verify(arrow, atLeastOnce()).remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndTransferRetainRecoveryAndRemoveTheOwnedArrow() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var arrow = arrow(a, new Location(a.world, 2, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("longbow");
|
||||
assertNotNull(deadline);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
var unlearn = abilityButton(a, "[Unlearn]", "longbow");
|
||||
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
|
||||
assertFalse(refunded.learned().contains("longbow")); assertEquals(deadline, refunded.cooldowns().get("longbow"));
|
||||
verify(arrow, atLeastOnce()).remove();
|
||||
learnAndAssign(f, a); drawAndRelease(f, a, 20);
|
||||
verify(a.player, times(1)).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
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);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
|
||||
f.players.put(b.core.id, b.player); f.activate(b); drawAndRelease(f, b, 20);
|
||||
verify(b.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedRecoveryPersistenceLaunchesNoArrow() {
|
||||
var storage = mock(CharacterStorage.class);
|
||||
var sessions = 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 longbow = new games.dmg.fantasystick.ability.Longbow(
|
||||
games.dmg.fantasystick.ability.Longbow.Settings.defaults()); registry.register(longbow.definition());
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new EnumMap<>(base.stats()); stats.put(Stat.DEX, BigInteger.valueOf(14));
|
||||
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
|
||||
base.unspentPoints(), base.health(), java.util.Set.of("longbow"),
|
||||
List.of("longbow", "", "", "", "", "", ""), 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 = mock(org.bukkit.entity.Player.class);
|
||||
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
|
||||
var failed = new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();
|
||||
failed.completeExceptionally(new java.io.IOException("simulated Longbow recovery failure"));
|
||||
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("longbow"), any())).thenReturn(failed);
|
||||
var failures = new 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, "longbow",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.DRAW_RELEASE, null, 20, 0, () -> true));
|
||||
verify(player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
assertEquals(1, failures.size()); engine.close(); longbow.close(); parties.close();
|
||||
}
|
||||
|
||||
@Test void acknowledgedConfigurationChangesOnlyFutureShotSnapshots() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var oldArrow = arrow(a, new Location(a.world, 3, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(oldArrow)); return oldArrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
|
||||
var oldTarget = targetRecordingDamage(f, a, oldArrow); f.dispatcher.accept(new ProjectileHitEvent(oldArrow, oldTarget.entity()));
|
||||
assertEquals(7.0, oldTarget.amount()[0], 1.0e-9);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, b, 14);
|
||||
f.players.put(b.core.id, b.player); f.activate(b); when(b.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var newArrow = arrow(b, new Location(b.world, 3, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(newArrow)); return newArrow; })
|
||||
.when(b.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
var newTarget = targetRecordingDamage(f, b, newArrow);
|
||||
drawAndRelease(f, b, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(new ProjectileHitEvent(newArrow, newTarget.entity()));
|
||||
assertEquals(11.0, newTarget.amount()[0], 1.0e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAdminWriteKeepsLongbowDefinitionAndExternalFileUnchanged() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var outside = data.resolveSibling(data.getFileName() + "-longbow-outside");
|
||||
java.nio.file.Files.writeString(outside, "untouched\n");
|
||||
java.nio.file.Files.createSymbolicLink(data.resolve("config.yml"), outside);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("5"), registry.find("longbow").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
assertEquals("untouched\n", java.nio.file.Files.readString(outside));
|
||||
java.nio.file.Files.delete(data.resolve("config.yml")); java.nio.file.Files.delete(outside);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reenableDrainsAcceptedLongbowConfigurationBeforeGameplayRegistration() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var settings = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "longbowSettings");
|
||||
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(settings, "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
|
||||
worker.execute(() -> {
|
||||
entered.countDown();
|
||||
try { release.await(5, java.util.concurrent.TimeUnit.SECONDS); }
|
||||
catch (InterruptedException failure) { Thread.currentThread().interrupt(); }
|
||||
});
|
||||
try {
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
assertNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"));
|
||||
release.countDown(); f.tick();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("9"), registry.find("longbow").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionFormulaValidationAndAdminRoutesCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"longbow-base-damage: 8\nlongbow-recovery-seconds: 2\nlongbow-minimum-draw-ticks: 4\n"
|
||||
+ "longbow-full-draw-ticks: 30\nlongbow-range-blocks: 70\nlongbow-lifetime-seconds: 11\n"
|
||||
+ "longbow-full-projectile-speed-blocks-per-tick: 4\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("longbow").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
|
||||
assertEquals(new java.math.BigDecimal("8"), definition.parameters().get("base-damage"));
|
||||
assertEquals(new java.math.BigDecimal("4"), definition.parameters().get("minimum-draw-ticks"));
|
||||
assertEquals(new java.math.BigDecimal("30"), definition.parameters().get("full-draw-ticks"));
|
||||
assertEquals(0, new java.math.BigDecimal("70").compareTo(definition.parameters().get("range")));
|
||||
assertEquals(0, new java.math.BigDecimal("11").compareTo(definition.parameters().get("lifetime")));
|
||||
assertEquals(0, new java.math.BigDecimal("4").compareTo(definition.parameters().get("full-projectile-speed")));
|
||||
assertEquals(java.math.BigDecimal.ONE, games.dmg.fantasystick.ability.Longbow.power(30, 30));
|
||||
assertEquals(0, games.dmg.fantasystick.ability.Longbow.power(15, 30)
|
||||
.compareTo(new java.math.BigDecimal("0.4166666666666666666666666666666667")));
|
||||
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.Longbow.fullDamage(BigInteger.valueOf(14)));
|
||||
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(5)),
|
||||
games.dmg.fantasystick.ability.Longbow.fullDamage(BigInteger.TEN.pow(500)));
|
||||
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);
|
||||
assertTrue(PartyBookNativeTest.text(unavailable).replaceAll("\\s+", "").contains("Unavailable:requirements"));
|
||||
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "longbow"));
|
||||
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(java.util.List.of("config", "inspect", "recover"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(java.util.List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"c"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown"}).isEmpty());
|
||||
assertEquals(java.util.List.of("longbow-base-damage"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "longbow-b"}));
|
||||
assertEquals(7, f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "longbow-"}).size());
|
||||
assertEquals(java.util.List.of("5", "8"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "longbow-base-damage", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage"});
|
||||
assertTrue(sender.messages.getLast().contains("longbow-base-damage=8"));
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "longbow"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied"), sender.messages.toString()); sender.allowed = true;
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertEquals(new java.math.BigDecimal("9"), registry.find("longbow").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "-1"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("9"), reloaded.find("longbow").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
}
|
||||
|
||||
for (String invalid : List.of("longbow-base-damage: -1\n", "longbow-recovery-seconds: 0.5\n",
|
||||
"longbow-minimum-draw-ticks: 0\n", "longbow-full-draw-ticks: 2\n",
|
||||
"longbow-range-blocks: 0\n", "longbow-lifetime-seconds: 0\n",
|
||||
"longbow-full-projectile-speed-blocks-per-tick: 0\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(invalid.hashCode()));
|
||||
java.nio.file.Files.createDirectories(directory); java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
|
||||
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record DamageTarget(LivingEntity entity, double[] amount) { }
|
||||
|
||||
private static DamageTarget targetRecordingDamage(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor, Arrow arrow) {
|
||||
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getLocation()).thenReturn(new Location(actor.world, 3, 65.6, 0));
|
||||
var amount = new double[1];
|
||||
doAnswer(call -> {
|
||||
amount[0] = call.getArgument(0); var source = call.getArgument(1, DamageSource.class);
|
||||
fixture.dispatcher.accept(new EntityDamageByEntityEvent(arrow, target,
|
||||
EntityDamageEvent.DamageCause.PROJECTILE, source, amount[0])); return null;
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class));
|
||||
return new DamageTarget(target, amount);
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "longbow"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "longbow"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static void drawAndRelease(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor, int ticks) {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var eye = new Location(actor.world, 0, 65.6, 0); eye.setDirection(new Vector(1, 0, 0));
|
||||
doReturn(eye).when(actor.player).getEyeLocation();
|
||||
var bow = actor.core.inventory.getItem(2).clone();
|
||||
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
bow, null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
fixture.dispatcher.accept(new io.papermc.paper.event.player.PlayerStopUsingItemEvent(actor.player, bow, ticks));
|
||||
}
|
||||
|
||||
private static Arrow arrow(FantasyStickLifecycleNativeTest.Actor actor, Location location) {
|
||||
var arrow = PartyCombatNativeTest.entity(Arrow.class);
|
||||
when(arrow.getShooter()).thenReturn(actor.player); when(arrow.getLocation()).thenReturn(location);
|
||||
when(arrow.getWorld()).thenReturn(actor.world); return arrow;
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
|
||||
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(book);
|
||||
for (var page : book.pages()) {
|
||||
var found = abilityButton(page.raw(), label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
throw new AssertionError("Missing " + label + " action for " + ability);
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component,
|
||||
String label, String ability) {
|
||||
if (component.getString().equals(label)
|
||||
&& component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
|
||||
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
|
||||
&& payload.value().contains(":" + ability)) return custom;
|
||||
for (var child : component.getSiblings()) {
|
||||
var found = abilityButton(child, label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void withStats(Path data, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
int intelligence, int dexterity) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new EnumMap<>(old.stats()); stats.put(Stat.INT, BigInteger.valueOf(intelligence));
|
||||
stats.put(Stat.DEX, BigInteger.valueOf(dexterity));
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), BigInteger.valueOf(40), old.health(), old.learned(), old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
|
||||
private static void withLongbow(Path data, FantasyStickLifecycleNativeTest.Actor actor, int dexterity) throws Exception {
|
||||
withStats(data, actor, 0, dexterity);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE), old.health(),
|
||||
java.util.Set.of("longbow"), List.of("longbow", "", "", "", "", "", ""), old.cooldowns(),
|
||||
old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class NutritionNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void exitEatingAndReentryCannotHealTheSavedCharacterOrLoseTheNewOrdinaryNutrition() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player);
|
||||
a.player.setHealth(12); a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
|
||||
f.activate(a); a.player.setHealth(4);
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(12, a.player.getHealth()); assertEquals(7, a.player.getFoodLevel());
|
||||
a.player.setHealth(a.maximumHealth.getValue());
|
||||
a.player.setFoodLevel(20); a.player.setSaturation(12); a.player.setExhaustion(0.5f);
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
assertEquals(4, a.player.getHealth(), "Ordinary eating/healing cannot refill transferable fantasy health");
|
||||
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(a.maximumHealth.getValue(), a.player.getHealth());
|
||||
assertEquals(20, a.player.getFoodLevel()); assertEquals(12, a.player.getSaturation());
|
||||
assertEquals(0.5f, a.player.getExhaustion());
|
||||
var characters = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(0, characters.find(a.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.orElseThrow().health().compareTo(java.math.BigDecimal.valueOf(4)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedFantasyFoodSetterRestoresTheCapturedOrdinaryNutritionAndRetainsRecoveryProof() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player);
|
||||
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
|
||||
a.foodMutation = () -> {
|
||||
a.foodMutation = () -> {};
|
||||
throw new IllegalStateException("Injected food setter failure");
|
||||
};
|
||||
f.activate(a);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
assertTrue(sessions.active(a.core.id).isEmpty());
|
||||
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
|
||||
assertEquals(3, a.player.getExhaustion());
|
||||
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.orElseThrow().pending());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void fantasyDefeatRestoresOrdinaryNutritionInsteadOfLeavingTheFullFantasyState() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player);
|
||||
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
|
||||
f.activate(a);
|
||||
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
|
||||
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
|
||||
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(a.player, source, new java.util.ArrayList<>(), 0,
|
||||
net.kyori.adventure.text.Component.empty(), true);
|
||||
f.dispatcher.accept(death); PartyBookNativeTest.drain(f);
|
||||
assertTrue(death.isCancelled());
|
||||
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
|
||||
assertEquals(3, a.player.getExhaustion());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void activeFantasyModeKeepsFoodFullBlocksFoodHealingAndRestoresOrdinaryNutrition() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
ordinary.core.inventory.setItemInOffHand(null);
|
||||
f.players.put(a.core.id, a.player); f.players.put(ordinary.core.id, ordinary.player);
|
||||
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
|
||||
ordinary.player.setFoodLevel(8); ordinary.player.setExhaustion(4);
|
||||
f.activate(a);
|
||||
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
|
||||
|
||||
var drain = new org.bukkit.event.entity.FoodLevelChangeEvent(a.player, 19);
|
||||
var exhaustion = new org.bukkit.event.entity.EntityExhaustionEvent(a.player,
|
||||
org.bukkit.event.entity.EntityExhaustionEvent.ExhaustionReason.SPRINT, 0.1f);
|
||||
var foodHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
|
||||
var potionHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC);
|
||||
var abilityHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
f.dispatcher.accept(drain); f.dispatcher.accept(exhaustion);
|
||||
f.dispatcher.accept(foodHealing); f.dispatcher.accept(potionHealing); f.dispatcher.accept(abilityHealing);
|
||||
assertTrue(drain.isCancelled()); assertTrue(exhaustion.isCancelled()); assertTrue(foodHealing.isCancelled());
|
||||
assertFalse(potionHealing.isCancelled(), "Explicit native magic healing remains available");
|
||||
assertFalse(abilityHealing.isCancelled(), "Explicit ability healing remains available");
|
||||
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
|
||||
|
||||
var ordinaryDrain = new org.bukkit.event.entity.FoodLevelChangeEvent(ordinary.player, 7);
|
||||
var ordinaryHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(ordinary.player, 1,
|
||||
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
|
||||
f.dispatcher.accept(ordinaryDrain); f.dispatcher.accept(ordinaryHealing);
|
||||
assertFalse(ordinaryDrain.isCancelled()); assertFalse(ordinaryHealing.isCancelled());
|
||||
assertEquals(8, ordinary.player.getFoodLevel()); assertEquals(4, ordinary.player.getExhaustion());
|
||||
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
|
||||
assertEquals(3, a.player.getExhaustion());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,7 +234,7 @@ class PartyBookNativeTest {
|
||||
open(f, a); var oldDisband = button(a, "[Disband party]");
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
|
||||
open(f, b);
|
||||
assertTrue(text(b).contains(a.core.record.name()));
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class QuickstepNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedUseAppliesAndStopsOwnedHorizontalVelocityAtFourBlocks() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, false);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("Quickstep")); assertTrue(text.contains("DEX12"));
|
||||
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("4")); assertTrue(text.contains("5s"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "quickstep"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "quickstep"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
|
||||
|
||||
var velocity = prepareMovement(a, new Vector(0, 0.25, 0));
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(new Vector(1, 0.25, 0), velocity.get());
|
||||
verify(a.player, never()).setFallDistance(anyFloat());
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"));
|
||||
var from = new Location(a.world, 0, 64, 0); var to = new Location(a.world, 4, 64, 0);
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, to));
|
||||
assertEquals(new Vector(0, 0.25, 0), velocity.get(), "Only remaining owned horizontal motion is removed");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void airborneFlyingGlidingOrMountedUseRefusesBeforeCooldownAndMovement() throws Exception {
|
||||
for (int route = 0; route < 4; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve(Integer.toString(route)))) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve(Integer.toString(route)));
|
||||
withStats(data.resolve(Integer.toString(route)), a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var velocity = prepareMovement(a, new Vector(0.3, 0.4, 0.5));
|
||||
doReturn(route != 0).when(a.player).isOnGround();
|
||||
doReturn(route == 1).when(a.player).isFlying();
|
||||
doReturn(route == 2).when(a.player).isGliding();
|
||||
doReturn(route == 3).when(a.player).isInsideVehicle();
|
||||
use(f, a);
|
||||
assertEquals(new Vector(0.3, 0.4, 0.5), velocity.get());
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("quickstep"));
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("not eligible")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void velocityCancellationObstacleAndUnrelatedReplacementAreAuthoritative() throws Exception {
|
||||
for (int route = 0; route < 3; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("motion-" + route))) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("motion-" + route));
|
||||
withStats(data.resolve("motion-" + route), a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var velocity = prepareMovement(a, new Vector(0, 0.3, 0));
|
||||
if (route == 0) {
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler public void velocity(org.bukkit.event.player.PlayerVelocityEvent event) {
|
||||
if (event.getPlayer() == a.player) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Veto(), f.plugin);
|
||||
}
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"));
|
||||
if (route == 0) {
|
||||
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Cancelled velocity applies no movement");
|
||||
} else if (route == 1) {
|
||||
velocity.set(new Vector(0, 0.3, 0));
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
|
||||
new Location(a.world, 0.2, 64, 0)));
|
||||
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Native collision stops movement early");
|
||||
} else {
|
||||
velocity.set(new Vector(0, 0.6, 2));
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
|
||||
new Location(a.world, 0.2, 64, 0)));
|
||||
assertEquals(new Vector(0, 0.6, 2), velocity.get(), "Replacement movement relinquishes ownership");
|
||||
}
|
||||
verify(a.player, never()).setFallDistance(anyFloat());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedHorizontalMotion() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); var velocity = prepareMovement(a, new Vector(0, 0.4, 0));
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"); assertNotNull(deadline);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
var unlearn = abilityButton(a, "[Unlearn]", "quickstep");
|
||||
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("quickstep"));
|
||||
assertEquals(new Vector(0, 0.4, 0), velocity.get());
|
||||
learnAndAssign(f, a); use(f, a);
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
assertEquals(new Vector(0, 0.4, 0), velocity.get());
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
|
||||
f.players.put(b.core.id, b.player); f.activate(b); prepareMovement(b, new Vector(0.2, 0.3, 0.4)); use(f, b);
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceAppliesNoVelocity() {
|
||||
var storage = mock(CharacterStorage.class);
|
||||
var sessions = 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 quickstep = new games.dmg.fantasystick.ability.Quickstep(
|
||||
games.dmg.fantasystick.ability.Quickstep.Settings.defaults(), ignored -> {});
|
||||
registry.register(quickstep.definition());
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new EnumMap<>(base.stats()); stats.put(Stat.DEX, BigInteger.valueOf(12));
|
||||
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
|
||||
base.unspentPoints(), base.health(), java.util.Set.of("quickstep"),
|
||||
List.of("quickstep", "", "", "", "", "", ""), 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 = mock(org.bukkit.entity.Player.class);
|
||||
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
|
||||
doReturn(true).when(player).isOnGround(); doReturn(false).when(player).isFlying();
|
||||
doReturn(false).when(player).isGliding(); doReturn(false).when(player).isInsideVehicle();
|
||||
var eye = new Location(mock(org.bukkit.World.class), 0, 0, 0); eye.setDirection(new Vector(1, 0, 0));
|
||||
doReturn(eye).when(player).getEyeLocation();
|
||||
var failed = new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();
|
||||
failed.completeExceptionally(new java.io.IOException("simulated Quickstep cooldown failure"));
|
||||
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("quickstep"), 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, "quickstep",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
|
||||
verify(player, never()).setVelocity(any(Vector.class)); assertEquals(1, failures.size());
|
||||
engine.close(); quickstep.close(); parties.close();
|
||||
}
|
||||
|
||||
@Test void acknowledgedUpdatesAffectFutureDashesWhileActiveDashKeepsCapturedBounds() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); var oldVelocity = prepareMovement(a, new Vector(0, 0.2, 0));
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "7"});
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-speed-blocks-per-tick", "2"});
|
||||
for (int i = 0; i < 2; i++) {
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
}
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
|
||||
new Location(a.world, 4, 64, 0)));
|
||||
assertEquals(new Vector(0, 0.2, 0), oldVelocity.get(), "Active dash retains its four-block snapshot");
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, b, 12, true);
|
||||
f.players.put(b.core.id, b.player); f.activate(b); var newVelocity = prepareMovement(b, new Vector(0, 0.3, 0));
|
||||
use(f, b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(new Vector(2, 0.3, 0), newVelocity.get(), "Future dash uses acknowledged speed");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRoutesAndStartupValidationCoverEveryQuickstepSetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"quickstep-cooldown-seconds: 2\nquickstep-distance-blocks: 6\nquickstep-speed-blocks-per-tick: 1.5\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("quickstep").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
|
||||
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("distance")));
|
||||
assertEquals(0, new java.math.BigDecimal("1.5").compareTo(definition.parameters().get("speed")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("config", "inspect", "recover"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"co"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unsupported"}).isEmpty());
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quickstep"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "7"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("quickstep-cooldown-seconds", "quickstep-distance-blocks", "quickstep-speed-blocks-per-tick"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quickstep-"}));
|
||||
assertEquals(List.of("4", "6"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "quickstep-distance-blocks", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks"});
|
||||
assertTrue(sender.messages.getLast().contains("=6"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "7"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertEquals(0, new java.math.BigDecimal("7").compareTo(registry.find("quickstep").orElseThrow()
|
||||
.parameters().get("distance")));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
f.command.execute(sender.port, "fantasystick",
|
||||
new String[]{"config", "quickstep-distance-blocks", "7", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(0, new java.math.BigDecimal("7").compareTo(reloaded.find("quickstep").orElseThrow()
|
||||
.parameters().get("distance")));
|
||||
}
|
||||
for (String invalid : List.of("quickstep-cooldown-seconds: -1\n", "quickstep-distance-blocks: 0\n",
|
||||
"quickstep-speed-blocks-per-tick: 0\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(invalid.hashCode()));
|
||||
java.nio.file.Files.createDirectories(directory); java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
|
||||
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "quickstep"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "quickstep"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static AtomicReference<Vector> prepareMovement(FantasyStickLifecycleNativeTest.Actor actor, Vector initial) {
|
||||
var velocity = new AtomicReference<>(initial.clone());
|
||||
doReturn(true).when(actor.player).isOnGround(); doReturn(false).when(actor.player).isFlying();
|
||||
doReturn(false).when(actor.player).isGliding(); doReturn(false).when(actor.player).isInsideVehicle();
|
||||
doAnswer(ignored -> velocity.get().clone()).when(actor.player).getVelocity();
|
||||
doAnswer(call -> { velocity.set(call.getArgument(0, Vector.class).clone()); return null; }).when(actor.player).setVelocity(any(Vector.class));
|
||||
var eye = new Location(actor.world, 0, 65.6, 0); eye.setDirection(new Vector(1, 0, 0));
|
||||
doReturn(eye).when(actor.player).getEyeLocation();
|
||||
return velocity;
|
||||
}
|
||||
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
|
||||
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(book);
|
||||
for (var page : book.pages()) {
|
||||
var found = abilityButton(page.raw(), label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
throw new AssertionError("Missing " + label + " action for " + ability);
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component,
|
||||
String label, String ability) {
|
||||
if (component.getString().equals(label)
|
||||
&& component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
|
||||
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
|
||||
&& payload.value().contains(":" + ability)) return custom;
|
||||
for (var child : component.getSiblings()) {
|
||||
var found = abilityButton(child, label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void withStats(Path data, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
int dexterity, boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(data);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new EnumMap<>(old.stats()); stats.put(Stat.DEX, BigInteger.valueOf(dexterity));
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), old.health(),
|
||||
learned ? java.util.Set.of("quickstep") : old.learned(),
|
||||
learned ? List.of("quickstep", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ReviveNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void completedProgressionBudgetCanLearnAndAssignReviveWithoutALevelGate() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
try (var storage = new games.dmg.fantasystick.persistence.CharacterStorage(repository, java.time.Clock.systemUTC())) {
|
||||
var current = storage.awardExperience(actor.core.record.id(), actor.core.record.revision(),
|
||||
BigInteger.valueOf(2700), games.dmg.fantasystick.progression.ProgressionRules.defaults()).join().record().orElseThrow();
|
||||
for (var purchase : java.util.List.of(
|
||||
java.util.Map.entry(games.dmg.fantasystick.progression.Stat.WIS, 30),
|
||||
java.util.Map.entry(games.dmg.fantasystick.progression.Stat.CHR, 24),
|
||||
java.util.Map.entry(games.dmg.fantasystick.progression.Stat.CON, 12))) {
|
||||
for (int count = 0; count < purchase.getValue(); count++) {
|
||||
current = storage.purchaseStat(current.id(), current.revision(), purchase.getKey(), current.health(),
|
||||
new java.math.BigDecimal("1024")).join().record().orElseThrow();
|
||||
}
|
||||
}
|
||||
assertEquals(28, current.level()); assertEquals(BigInteger.ONE, current.unspentPoints());
|
||||
}
|
||||
fixture.plugin.setEnabled(true); fixture.players.put(actor.core.id, actor.player); fixture.activate(actor);
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
String text = PartyBookNativeTest.text(actor).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("Revive")); assertTrue(text.contains("WIS30")); assertTrue(text.contains("CHR24"));
|
||||
assertTrue(text.contains("CON12")); assertTrue(text.contains("Cooldown:86400s"));
|
||||
PartyBookNativeTest.click(fixture, actor, PartyBookNativeTest.button(actor, "[Learn]"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, PartyBookNativeTest.button(actor, "[3]"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
var saved = storage.find(actor.core.record.id()).get().orElseThrow();
|
||||
assertTrue(saved.learned().contains("revive")); assertEquals("revive", saved.loadout().getFirst());
|
||||
assertEquals(BigInteger.ZERO, saved.unspentPoints());
|
||||
assertEquals(org.bukkit.Material.ECHO_SHARD, actor.core.inventory.getItem(2).getType());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ordinaryTargetSelectsOneTailAndReceivesTheSameLivingSheetOnce() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player);
|
||||
fixture.activate(caster);
|
||||
assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions")).active(target.core.id).isEmpty());
|
||||
|
||||
var original = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var death = java.util.UUID.randomUUID();
|
||||
var died = java.time.Instant.now().minusSeconds(3600).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), "Fallen Mira", original.revision(),
|
||||
original.stats(), 7, BigInteger.valueOf(642), BigInteger.valueOf(11), java.math.BigDecimal.ZERO,
|
||||
java.util.Set.of("fireball"), java.util.List.of("fireball", "", "", "", "", "", ""),
|
||||
java.util.Map.of("fireball", java.time.Instant.now().plusSeconds(30)), original.restrictedKills(), true, death, died);
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(dead);
|
||||
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead);
|
||||
var copiedStack = tail.clone(); copiedStack.setAmount(2); target.core.inventory.setItem(10, copiedStack);
|
||||
|
||||
caster.core.location.setWorld(caster.world); caster.core.location.setX(0); caster.core.location.setY(64); caster.core.location.setZ(0);
|
||||
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64); target.core.location.setZ(0);
|
||||
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
|
||||
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
|
||||
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
|
||||
caster.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
|
||||
org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
assertEquals(1, target.inventories.size());
|
||||
var popup = target.inventories.getFirst();
|
||||
assertEquals(1, java.util.stream.IntStream.range(0, popup.getSize()).filter(slot -> {
|
||||
var item = popup.getItem(slot); return item != null && item.getType() == org.bukkit.Material.WRITTEN_BOOK;
|
||||
}).count());
|
||||
var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
|
||||
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
|
||||
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
|
||||
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
|
||||
var selection = new org.bukkit.event.inventory.InventoryClickEvent(view,
|
||||
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
|
||||
fixture.dispatcher.accept(selection); assertTrue(selection.isCancelled(), "Popup copies cannot be exported");
|
||||
var revivalStore = (games.dmg.fantasystick.persistence.RevivalStore)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals");
|
||||
PartyBookNativeTest.drain(fixture);
|
||||
assertTrue(revivalStore.find(dead.id()).join().isPresent());
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
var restored = storage.find(dead.id()).get().orElseThrow();
|
||||
assertFalse(restored.dead(), "caster=" + caster.messages + ", target=" + target.messages + ", source=" + target.core.inventory.getItem(10)
|
||||
+ ", reservation=" + revivalStore.find(dead.id()).join());
|
||||
assertEquals(dead.id(), restored.id()); assertEquals(dead.name(), restored.name());
|
||||
assertEquals(dead.stats(), restored.stats()); assertEquals(dead.level(), restored.level());
|
||||
assertEquals(dead.experience(), restored.experience()); assertEquals(dead.unspentPoints(), restored.unspentPoints());
|
||||
assertEquals(dead.learned(), restored.learned()); assertEquals(dead.loadout(), restored.loadout());
|
||||
assertEquals(dead.cooldowns(), restored.cooldowns()); assertEquals(restored.maximumHealth(), restored.health());
|
||||
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
|
||||
.identify(target.core.inventory.getItem(1)).orElseThrow());
|
||||
assertEquals(1, target.core.inventory.getItem(10).getAmount());
|
||||
assertEquals(new games.dmg.fantasystick.items.TailBooks.Identity(dead.id(), dead.deathId()),
|
||||
new games.dmg.fantasystick.items.TailBooks().identify(target.core.inventory.getItem(10)).orElseThrow());
|
||||
assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions")).active(target.core.id).isEmpty());
|
||||
fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
|
||||
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
|
||||
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
|
||||
.identify(target.core.inventory.getItem(1)).orElseThrow());
|
||||
assertTrue(storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive")
|
||||
.isAfter(java.time.Instant.now().plusSeconds(86300)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void stackedTailWithNoSafeSheetDestinationChangesNothingAndKeepsTheCooldown() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player); fixture.activate(caster);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var death = java.util.UUID.randomUUID(); var died = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Full inventory", base.revision(), base.stats(),
|
||||
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
|
||||
base.cooldowns(), base.restrictedKills(), true, death, died);
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(dead);
|
||||
for (int slot = 0; slot <= 40; slot++) if (slot <= 35 || slot == 40) {
|
||||
target.core.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
}
|
||||
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead); tail.setAmount(2); target.core.inventory.setItem(10, tail);
|
||||
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
|
||||
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64);
|
||||
org.mockito.Mockito.doReturn(new org.bukkit.Location(caster.world, 0, 65.6, 0)).when(caster.player).getEyeLocation();
|
||||
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
|
||||
caster.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
|
||||
org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
var popup = target.inventories.getFirst(); var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
|
||||
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
|
||||
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
|
||||
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
|
||||
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
|
||||
PartyBookNativeTest.drain(fixture);
|
||||
assertEquals(tail, target.core.inventory.getItem(10));
|
||||
assertTrue(((games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters")).find(dead.id()).get().orElseThrow().dead());
|
||||
assertTrue(((games.dmg.fantasystick.persistence.RevivalStore)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals")).find(dead.id()).join().isEmpty());
|
||||
assertTrue(target.messages.stream().anyMatch(message -> message.contains("empty direct inventory slot")));
|
||||
assertNotNull(((games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"))
|
||||
.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void confirmationRechecksTheExactTwentyFourHourBoundaryBeforeChangingTheTail() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player); fixture.activate(caster);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var death = java.util.UUID.randomUUID(); var recent = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Boundary", base.revision(), base.stats(),
|
||||
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
|
||||
base.cooldowns(), base.restrictedKills(), true, death, recent);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data); repository.create(dead);
|
||||
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead); target.core.inventory.setItem(10, tail);
|
||||
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
|
||||
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64);
|
||||
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0);
|
||||
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
|
||||
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
|
||||
caster.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
|
||||
org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
var popup = target.inventories.getFirst();
|
||||
var expired = new games.dmg.fantasystick.persistence.CharacterRecord(dead.id(), dead.name(), dead.revision().add(BigInteger.ONE),
|
||||
dead.stats(), dead.level(), dead.experience(), dead.unspentPoints(), dead.health(), dead.learned(), dead.loadout(),
|
||||
dead.cooldowns(), dead.restrictedKills(), true, dead.deathId(),
|
||||
java.time.Instant.now().minusSeconds(86400).truncatedTo(java.time.temporal.ChronoUnit.SECONDS));
|
||||
repository.replace(expired, dead.revision());
|
||||
var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
|
||||
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
|
||||
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
|
||||
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
|
||||
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
|
||||
PartyBookNativeTest.drain(fixture);
|
||||
assertEquals(tail, target.core.inventory.getItem(10));
|
||||
assertTrue(((games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters")).find(dead.id()).get().orElseThrow().dead());
|
||||
assertTrue(((games.dmg.fantasystick.persistence.RevivalStore)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals")).find(dead.id()).join().isEmpty());
|
||||
assertTrue(caster.messages.stream().anyMatch(message -> message.contains("no longer eligible")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void eyeRayRequiresAValidTargetAndEmptyResultStillConsumesTheDailyCooldown() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player);
|
||||
fixture.activate(caster);
|
||||
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
|
||||
target.core.location.setWorld(caster.world); target.core.location.setX(12); target.core.location.setY(64);
|
||||
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
|
||||
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
|
||||
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
|
||||
caster.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var use = new org.bukkit.event.player.PlayerInteractEvent(caster.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
caster.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
fixture.dispatcher.accept(use);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
assertFalse(storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("revive"));
|
||||
|
||||
org.mockito.Mockito.doReturn(new org.bukkit.util.RayTraceResult(new org.bukkit.util.Vector(12, 65, 0), target.player))
|
||||
.when(caster.world).rayTraceEntities(org.mockito.ArgumentMatchers.any(org.bukkit.Location.class),
|
||||
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class), org.mockito.ArgumentMatchers.eq(12.0),
|
||||
org.mockito.ArgumentMatchers.<java.util.function.Predicate<org.bukkit.entity.Entity>>any());
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(caster.player,
|
||||
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, caster.core.inventory.getItem(2), null,
|
||||
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
assertTrue(storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive")
|
||||
.isAfter(java.time.Instant.now().plusSeconds(86300)));
|
||||
assertTrue(caster.messages.stream().anyMatch(message -> message.contains("no direct-inventory Tail")));
|
||||
assertTrue(target.inventories.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unlearningDuringFantasyTargetPopupCancelsSelectionWithoutResettingDailyDeadline() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player);
|
||||
fixture.activate(caster); fixture.activate(target);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var death = java.util.UUID.randomUUID(); var died = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Pending", base.revision(), base.stats(),
|
||||
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
|
||||
base.cooldowns(), base.restrictedKills(), true, death, died);
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(dead);
|
||||
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead); target.core.inventory.setItem(10, tail);
|
||||
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
|
||||
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64);
|
||||
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0);
|
||||
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
|
||||
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
|
||||
caster.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
|
||||
org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
assertEquals(1, target.inventories.size()); var popup = target.inventories.getFirst();
|
||||
assertTrue(fixture.delayedTicks.contains(6000L), "The popup expires at exactly five real-time minutes");
|
||||
var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
|
||||
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
|
||||
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
|
||||
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
|
||||
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[Unlearn]"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
var refunded = storage.find(caster.core.record.id()).get().orElseThrow();
|
||||
var deadline = refunded.cooldowns().get("revive"); assertNotNull(deadline); assertFalse(refunded.learned().contains("revive"));
|
||||
assertTrue(java.util.stream.IntStream.range(0, popup.getSize()).allMatch(slot -> popup.getItem(slot) == null));
|
||||
fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
|
||||
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
|
||||
assertTrue(storage.find(dead.id()).get().orElseThrow().dead()); assertEquals(tail, target.core.inventory.getItem(10));
|
||||
|
||||
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[Learn]"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[3]"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
caster.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
|
||||
org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(fixture);
|
||||
assertEquals(1, target.inventories.size(), "Relearning cannot bypass the original daily deadline");
|
||||
assertEquals(deadline, storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive"));
|
||||
|
||||
var transferred = caster.core.inventory.getItemInOffHand().clone();
|
||||
caster.core.inventory.setItemInOffHand(null); fixture.changed(caster);
|
||||
PartyBookNativeTest.drain(fixture); fixture.awaitCharacterAndReceipt(caster);
|
||||
var current = storage.find(caster.core.record.id()).get().orElseThrow();
|
||||
var renamed = storage.renameCharacter(current.id(), "Renamed daily caster").get().orElseThrow();
|
||||
var nextHolder = new FantasyStickLifecycleNativeTest.Actor(data); nextHolder.core.inventory.setItemInOffHand(transferred);
|
||||
fixture.players.put(nextHolder.core.id, nextHolder.player); fixture.activate(nextHolder);
|
||||
nextHolder.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(nextHolder.player, target.player,
|
||||
org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(fixture);
|
||||
assertTrue(nextHolder.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
assertEquals("Renamed daily caster", renamed.name()); assertEquals(deadline, renamed.cooldowns().get("revive"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void startupReconciliationCompletesOnlyAProvedDeliveredSheetAndClearsAnUnchangedTail() throws Exception {
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data); target.core.inventory.setItemInOffHand(null);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var death = java.util.UUID.randomUUID(); var died = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Interrupted", base.revision(), base.stats(),
|
||||
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
|
||||
base.cooldowns(), base.restrictedKills(), true, death, died);
|
||||
repository.create(dead);
|
||||
var provisional = new games.dmg.fantasystick.persistence.CharacterRecord(dead.id(), dead.name(), dead.revision(), dead.stats(),
|
||||
dead.level(), dead.experience(), dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(),
|
||||
dead.cooldowns(), dead.restrictedKills(), false, dead.deathId(), dead.diedAt());
|
||||
target.core.inventory.setItem(10, new games.dmg.fantasystick.items.CharacterSheets().create(provisional));
|
||||
var reservation = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(java.util.UUID.randomUUID(),
|
||||
java.util.UUID.randomUUID(), target.core.id, dead.id(), death, java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),
|
||||
10, 1, -1);
|
||||
try (var store = new games.dmg.fantasystick.persistence.RevivalStore(data)) { store.reserve(reservation).join(); }
|
||||
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.players.put(target.core.id, target.player); fixture.plugin.setEnabled(true);
|
||||
var store = (games.dmg.fantasystick.persistence.RevivalStore)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals");
|
||||
store.target(target.core.id).join(); PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
assertFalse(storage.find(dead.id()).get().orElseThrow().dead());
|
||||
assertTrue(store.find(dead.id()).join().isEmpty());
|
||||
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
|
||||
.identify(target.core.inventory.getItem(10)).orElseThrow());
|
||||
|
||||
var secondBase = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var secondDeath = java.util.UUID.randomUUID();
|
||||
var second = new games.dmg.fantasystick.persistence.CharacterRecord(secondBase.id(), "Not delivered", secondBase.revision(),
|
||||
secondBase.stats(), secondBase.level(), secondBase.experience(), secondBase.unspentPoints(), java.math.BigDecimal.ZERO,
|
||||
secondBase.learned(), secondBase.loadout(), secondBase.cooldowns(), secondBase.restrictedKills(), true, secondDeath, died);
|
||||
repository.create(second); var tail = new games.dmg.fantasystick.items.TailBooks().create(second); target.core.inventory.setItem(12, tail);
|
||||
var untouched = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(java.util.UUID.randomUUID(),
|
||||
java.util.UUID.randomUUID(), target.core.id, second.id(), secondDeath,
|
||||
java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS), 12, 1, -1);
|
||||
store.reserve(untouched).join();
|
||||
((games.dmg.fantasystick.ability.Revive) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revive")).reconcile(target.player);
|
||||
store.target(target.core.id).join(); PartyBookNativeTest.drain(fixture);
|
||||
assertTrue(storage.find(second.id()).get().orElseThrow().dead());
|
||||
assertEquals(tail, target.core.inventory.getItem(12)); assertTrue(store.find(second.id()).join().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private static void withRevive(Path data, FantasyStickLifecycleNativeTest.Actor actor) 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.WIS, BigInteger.valueOf(30));
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.CHR, BigInteger.valueOf(24));
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.CON, BigInteger.valueOf(12));
|
||||
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.maximumHealth(), java.util.Set.of("revive"),
|
||||
java.util.List.of("revive", "", "", "", "", "", ""), old.cooldowns(), old.restrictedKills(), false, null, null);
|
||||
repository.replace(saved, old.revision());
|
||||
}
|
||||
|
||||
@Test void packagedDefinitionMatchesReviewedReviveContract() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var revive = registry.find("revive").orElseThrow();
|
||||
assertEquals(org.bukkit.Material.ECHO_SHARD, revive.icon());
|
||||
assertEquals(BigInteger.ONE, revive.pointCost());
|
||||
assertEquals(BigInteger.valueOf(30), revive.requirements().get(games.dmg.fantasystick.progression.Stat.WIS));
|
||||
assertEquals(BigInteger.valueOf(24), revive.requirements().get(games.dmg.fantasystick.progression.Stat.CHR));
|
||||
assertEquals(BigInteger.valueOf(12), revive.requirements().get(games.dmg.fantasystick.progression.Stat.CON));
|
||||
assertEquals(Duration.ofHours(24), revive.cooldown());
|
||||
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.USE, revive.gesture());
|
||||
assertEquals(games.dmg.fantasystick.ability.AbilityDefinition.Activation.SELECTION, revive.activation());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
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 SecondWindNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionActualNativeHealPersistsBeforePublicationAndRetainsCooldown() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, false, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("SecondWind")); assertTrue(text.contains("CON12")); assertTrue(text.contains("WIS12"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "second_wind"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "second_wind"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(4, a.player.getHealth());
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var saved = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(0, BigDecimal.valueOf(11).compareTo(saved.health())); assertEquals(11, a.player.getHealth());
|
||||
assertNotNull(saved.cooldowns().get("second_wind"));
|
||||
assertFalse(a.messages.stream().anyMatch(message -> message.contains("support")));
|
||||
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);
|
||||
assertEquals(11, a.player.getHealth(), "Persisted healing survives exit and resume");
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
var unlearn = abilityButton(a, "[Unlearn]", "second_wind");
|
||||
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
|
||||
assertEquals(saved.cooldowns().get("second_wind"), refunded.cooldowns().get("second_wind"));
|
||||
learnAndAssign(f, a); use(f, a);
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void fullHealthAndCancelledNativeHealingApplyNoHealth() throws Exception {
|
||||
for (boolean full : new boolean[]{true, false}) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve(Boolean.toString(full)))) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve(Boolean.toString(full)));
|
||||
withSecondWind(data.resolve(Boolean.toString(full)), a, true, BigDecimal.valueOf(full ? 22 : 4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
if (!full) {
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler public void heal(EntityRegainHealthEvent event) {
|
||||
if (event.getEntity() == a.player && event.getRegainReason() == EntityRegainHealthEvent.RegainReason.CUSTOM) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Veto(), f.plugin);
|
||||
}
|
||||
double before = a.player.getHealth(); use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(before, a.player.getHealth());
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var saved = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
if (full) assertFalse(saved.cooldowns().containsKey("second_wind"));
|
||||
else assertNotNull(saved.cooldowns().get("second_wind"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedHealthPersistencePublishesNoNativeHealing() {
|
||||
var characters = mock(CharacterStorage.class);
|
||||
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
var attribute = mock(org.bukkit.attribute.AttributeInstance.class);
|
||||
when(player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(attribute);
|
||||
when(attribute.getValue()).thenReturn(22.0); when(player.getHealth()).thenReturn(4.0);
|
||||
var stats = new EnumMap<Stat, BigInteger>(Stat.class);
|
||||
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
|
||||
stats.put(Stat.CON, BigInteger.valueOf(12)); stats.put(Stat.WIS, BigInteger.valueOf(12));
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var record = new CharacterRecord(base.id(), base.name(), BigInteger.ONE, stats, base.level(), base.experience(),
|
||||
base.unspentPoints(), BigDecimal.valueOf(4), java.util.Set.of("second_wind"),
|
||||
List.of("second_wind", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
|
||||
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
|
||||
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
|
||||
java.util.UUID.randomUUID(), record.id());
|
||||
when(input.player()).thenReturn(player); when(input.session()).thenReturn(active);
|
||||
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
var effect = new games.dmg.fantasystick.ability.SecondWind(
|
||||
games.dmg.fantasystick.ability.SecondWind.Settings.defaults(), characters, sessions, Runnable::run,
|
||||
ignored -> {}, () -> BigDecimal.valueOf(1024));
|
||||
when(execution.stillCurrent()).thenReturn(true); when(execution.input()).thenReturn(input);
|
||||
when(execution.character()).thenReturn(record); when(execution.definition()).thenReturn(effect.definition());
|
||||
var failure = new java.util.concurrent.CompletableFuture<java.util.Optional<CharacterRecord>>();
|
||||
when(characters.saveHealth(eq(record.id()), eq(record.revision()), any(), any())).thenReturn(failure);
|
||||
effect.activate(execution); verify(player, never()).setHealth(anyDouble());
|
||||
failure.completeExceptionally(new java.io.IOException("simulated health save failure"));
|
||||
verify(player, never()).setHealth(anyDouble()); verify(execution).finish();
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionFormulaAndAdminRoutesCoverBothSettings() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"second-wind-base-healing: 8\nsecond-wind-cooldown-seconds: 20\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("second_wind").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(20), definition.cooldown());
|
||||
assertEquals(new BigDecimal("8"), definition.parameters().get("base-healing"));
|
||||
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
|
||||
games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.TEN.pow(500)));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("config", "inspect", "recover"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "9"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("second-wind-base-healing", "second-wind-cooldown-seconds"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second-wind-"}));
|
||||
assertEquals(List.of("6", "8"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "second-wind-base-healing", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing"});
|
||||
assertTrue(sender.messages.getLast().contains("=8"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "9"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertEquals(new BigDecimal("9"), registry.find("second_wind").orElseThrow().parameters().get("base-healing"));
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, true, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(14, a.player.getHealth(), "Future cast uses acknowledged base healing");
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "-1"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "9", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new BigDecimal("9"), reloaded.find("second_wind").orElseThrow().parameters().get("base-healing"));
|
||||
}
|
||||
for (String invalid : List.of("second-wind-base-healing: -1\n", "second-wind-cooldown-seconds: -1\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(invalid.hashCode()));
|
||||
java.nio.file.Files.createDirectories(directory); java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
|
||||
assertTrue(f.order.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "second_wind"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "second_wind"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
|
||||
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(book);
|
||||
for (var page : book.pages()) { var found = abilityButton(page.raw(), label, ability); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " action for " + ability);
|
||||
}
|
||||
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component, String label, String ability) {
|
||||
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
|
||||
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
|
||||
&& payload.value().contains(":" + ability)) return custom;
|
||||
for (var child : component.getSiblings()) { var found = abilityButton(child, label, ability); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void withSecondWind(Path data, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
boolean learned, BigDecimal health) throws Exception {
|
||||
java.nio.file.Files.createDirectories(data);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new EnumMap<>(old.stats()); stats.put(Stat.CON, BigInteger.valueOf(12)); stats.put(Stat.WIS, BigInteger.valueOf(12));
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), health,
|
||||
learned ? java.util.Set.of("second_wind") : old.learned(),
|
||||
learned ? List.of("second_wind", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
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 StoneSkinNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedUseReducesNativeEntityDamageForFourSeconds() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, false);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("StoneSkin")); assertTrue(text.contains("CON14"));
|
||||
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("25%")); assertTrue(text.contains("4s"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "stone_skin"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "stone_skin"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
|
||||
var attacker = mock(org.bukkit.entity.Zombie.class);
|
||||
var hit = new EntityDamageByEntityEvent(attacker, a.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(hit); assertEquals(6, hit.getDamage(), 1.0e-9);
|
||||
assertEquals(80L, f.delayedTicks.getLast());
|
||||
f.delayed.getLast().run();
|
||||
var expired = new EntityDamageByEntityEvent(attacker, a.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(expired); assertEquals(8, expired.getDamage(), 1.0e-9);
|
||||
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin");
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
var unlearn = abilityButton(a, "[Unlearn]", "stone_skin");
|
||||
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("stone_skin"));
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "stone_skin"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "stone_skin"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); use(f, a);
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void environmentalAndCancelledDamageRemainUntouchedWhileProjectileCombatIsReduced() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var fall = new EntityDamageEvent(a.player, EntityDamageEvent.DamageCause.FALL,
|
||||
mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(fall); assertEquals(8, fall.getDamage(), 1.0e-9);
|
||||
var arrow = mock(org.bukkit.entity.Arrow.class);
|
||||
var projectile = new EntityDamageByEntityEvent(arrow, a.player,
|
||||
EntityDamageEvent.DamageCause.PROJECTILE, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(projectile); assertEquals(6, projectile.getDamage(), 1.0e-9);
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.LOWEST)
|
||||
public void veto(EntityDamageByEntityEvent event) { event.setCancelled(true); }
|
||||
}
|
||||
f.dispatcher.register(new Veto(), f.plugin);
|
||||
var cancelled = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), a.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled()); assertEquals(8, cancelled.getDamage(), 1.0e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void replacementExpirySessionExitAndTransferRemoveOnlyOwnedReductionAndRetainDeadline() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"stone-skin-cooldown-seconds: 0\nstone-skin-duration-seconds: 4\nstone-skin-damage-reduction-percent: 25\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(2, f.delayed.size());
|
||||
f.delayed.getFirst().run();
|
||||
assertEquals(6, hit(f, a, 8).getDamage(), 1.0e-9, "Old expiry cannot remove replacement protection");
|
||||
f.delayed.getLast().run();
|
||||
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9);
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin");
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9, "Exit clears owned reduction");
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
|
||||
f.players.put(b.core.id, b.player); f.activate(b);
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedConfigurationAffectsFutureCastsWhileActiveProtectionKeepsSnapshot() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"stone-skin-cooldown-seconds: 0\nstone-skin-duration-seconds: 4\nstone-skin-damage-reduction-percent: 25\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "6"});
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-damage-reduction-percent", "50"});
|
||||
for (int i = 0; i < 2; i++) { var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run(); }
|
||||
assertEquals(6, hit(f, a, 8).getDamage(), 1.0e-9, "Active cast retains captured 25 percent");
|
||||
assertEquals(80L, f.delayedTicks.getFirst());
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, b, true);
|
||||
f.players.put(b.core.id, b.player); f.activate(b); use(f, b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(4, hit(f, b, 8).getDamage(), 1.0e-9); assertEquals(120L, f.delayedTicks.getLast());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acceptedDamageReportsExactPreventionWhileCancelledDamageReportsNothing() {
|
||||
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
|
||||
var stone = new games.dmg.fantasystick.ability.StoneSkin(
|
||||
games.dmg.fantasystick.ability.StoneSkin.Settings.defaults(), mitigation);
|
||||
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
|
||||
var player = mock(org.bukkit.entity.Player.class); var id = java.util.UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(id); when(input.player()).thenReturn(player);
|
||||
when(execution.input()).thenReturn(input); when(execution.definition()).thenReturn(stone.definition());
|
||||
when(execution.stillCurrent()).thenReturn(true);
|
||||
stone.activate(execution);
|
||||
var weaker = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
when(weaker.stillCurrent()).thenReturn(true);
|
||||
var unrelated = mitigation.grant(weaker, player, new java.math.BigDecimal("20"));
|
||||
var accepted = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
mitigation.reduce(accepted); mitigation.accepted(accepted);
|
||||
verify(execution).report(games.dmg.fantasystick.ability.AbilityReport.Type.PREVENTION,
|
||||
player, new java.math.BigDecimal("2.0"));
|
||||
var cancelled = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
mitigation.reduce(cancelled); cancelled.setCancelled(true); mitigation.accepted(cancelled);
|
||||
verify(execution, times(1)).report(any(), any(), any());
|
||||
verify(weaker, never()).report(any(), any(), any());
|
||||
try { unrelated.close(); } catch (Exception failure) { fail(failure); }
|
||||
stone.close(); mitigation.close();
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceCreatesNoReduction() {
|
||||
var storage = mock(CharacterStorage.class);
|
||||
var sessions = 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 mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
|
||||
var stone = new games.dmg.fantasystick.ability.StoneSkin(
|
||||
games.dmg.fantasystick.ability.StoneSkin.Settings.defaults(), mitigation); registry.register(stone.definition());
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new EnumMap<>(base.stats()); stats.put(Stat.CON, BigInteger.valueOf(14));
|
||||
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
|
||||
base.unspentPoints(), base.health(), java.util.Set.of("stone_skin"),
|
||||
List.of("stone_skin", "", "", "", "", "", ""), 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 = mock(org.bukkit.entity.Player.class); when(player.getUniqueId()).thenReturn(active.player());
|
||||
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
|
||||
var failed = new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();
|
||||
failed.completeExceptionally(new java.io.IOException("simulated cooldown save failure"));
|
||||
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("stone_skin"), 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, "stone_skin",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
|
||||
var hit = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 1.0e-9);
|
||||
verify(storage).commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("stone_skin"), any());
|
||||
engine.close(); stone.close(); mitigation.close(); parties.close();
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRoutesAndStartupValidationCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"stone-skin-cooldown-seconds: 2\nstone-skin-duration-seconds: 6\nstone-skin-damage-reduction-percent: 40\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("stone_skin").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
|
||||
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("duration-seconds")));
|
||||
assertEquals(0, new java.math.BigDecimal("40").compareTo(definition.parameters().get("damage-reduction-percent")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "stone-skin"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "7"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("stone-skin-cooldown-seconds", "stone-skin-damage-reduction-percent", "stone-skin-duration-seconds"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "stone-skin-"}));
|
||||
assertEquals(List.of("4", "6"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "stone-skin-duration-seconds", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds"});
|
||||
assertTrue(sender.messages.getLast().contains("=6"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "7"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertEquals(0, new java.math.BigDecimal("7").compareTo(
|
||||
registry.find("stone_skin").orElseThrow().parameters().get("duration-seconds")));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "-1"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "7", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(0, new java.math.BigDecimal("7").compareTo(
|
||||
reloaded.find("stone_skin").orElseThrow().parameters().get("duration-seconds")));
|
||||
}
|
||||
for (String invalid : List.of("stone-skin-cooldown-seconds: -1\n",
|
||||
"stone-skin-duration-seconds: 0\n", "stone-skin-damage-reduction-percent: 101\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
|
||||
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true)); assertTrue(f.order.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor target, double amount) {
|
||||
var event = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), target.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), amount);
|
||||
fixture.dispatcher.accept(event); return event;
|
||||
}
|
||||
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player,
|
||||
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, actor.core.inventory.getItem(2), null,
|
||||
org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor,
|
||||
String label, String ability) {
|
||||
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(book);
|
||||
for (var page : book.pages()) {
|
||||
var found = abilityButton(page.raw(), label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
fail("Missing " + label + " for " + ability); return null;
|
||||
}
|
||||
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component,
|
||||
String label, String ability) {
|
||||
if (component.getString().equals(label)
|
||||
&& component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
|
||||
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
|
||||
&& payload.value().contains(":" + ability)) return custom;
|
||||
for (var child : component.getSiblings()) {
|
||||
var found = abilityButton(child, label, ability); if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void withStoneSkin(Path data, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(data);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new EnumMap<>(old.stats()); stats.put(Stat.CON, BigInteger.valueOf(14));
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), old.health(),
|
||||
learned ? java.util.Set.of("stone_skin") : old.learned(),
|
||||
learned ? List.of("stone_skin", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,41 @@ class DistributionTest {
|
||||
assertEquals(1, metadata.getPermissions().size());
|
||||
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
|
||||
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
|
||||
var configuration = jar.getJarEntry("config.yml");
|
||||
assertNotNull(configuration, "Ship reviewed session defaults for administrators");
|
||||
String defaults;
|
||||
try (var input = jar.getInputStream(configuration)) {
|
||||
defaults = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
assertTrue(defaults.contains("character-swap-cooldown-seconds: 900"),
|
||||
"The delivered configuration must expose the reviewed 15-minute default");
|
||||
assertTrue(defaults.contains("adventure-mode: true"));
|
||||
assertTrue(defaults.contains("great-sword-base-damage: 7"));
|
||||
assertTrue(defaults.contains("great-sword-recovery-seconds: 1.2"));
|
||||
for (String setting : java.util.List.of("frost-shard-base-damage: 4",
|
||||
"frost-shard-cooldown-seconds: 5", "frost-shard-range-blocks: 32",
|
||||
"frost-shard-lifetime-seconds: 5", "frost-shard-projectile-speed-blocks-per-tick: 1",
|
||||
"frost-shard-slowness-amplifier: 0", "frost-shard-slowness-duration-seconds: 2")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged Frost Shard default: " + setting);
|
||||
}
|
||||
for (String setting : java.util.List.of("longbow-base-damage: 5", "longbow-recovery-seconds: 1",
|
||||
"longbow-minimum-draw-ticks: 3", "longbow-full-draw-ticks: 20",
|
||||
"longbow-range-blocks: 64", "longbow-lifetime-seconds: 10",
|
||||
"longbow-full-projectile-speed-blocks-per-tick: 3")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged Longbow default: " + setting);
|
||||
}
|
||||
for (String setting : java.util.List.of("quickstep-cooldown-seconds: 5",
|
||||
"quickstep-distance-blocks: 4", "quickstep-speed-blocks-per-tick: 1")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged Quickstep default: " + setting);
|
||||
}
|
||||
for (String setting : java.util.List.of("second-wind-base-healing: 6",
|
||||
"second-wind-cooldown-seconds: 15")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged Second Wind default: " + setting);
|
||||
}
|
||||
for (String setting : java.util.List.of("stone-skin-cooldown-seconds: 14",
|
||||
"stone-skin-duration-seconds: 4", "stone-skin-damage-reduction-percent: 25")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged Stone Skin default: " + setting);
|
||||
}
|
||||
assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
|
||||
var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class");
|
||||
assertNotNull(entrypoint);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package games.dmg.fantasystick.combat;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class BasicAttackTest {
|
||||
@Test void followsTheApprovedStrengthFormulaWithoutAStatCap() {
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.ZERO));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.TEN));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(11)));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.TWO, BasicAttack.damage(BigInteger.valueOf(14)));
|
||||
var huge = BigInteger.TEN.pow(1000);
|
||||
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO), BasicAttack.damage(huge));
|
||||
assertThrows(IllegalArgumentException.class, () -> BasicAttack.damage(BigInteger.valueOf(-1)));
|
||||
assertThrows(NullPointerException.class, () -> BasicAttack.damage(null));
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,273 @@ class CharacterStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void awardsConfiguredExperiencePersistentlyWithMultiLevelAndCapIdempotence() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var rules = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.valueOf(2), BigInteger.valueOf(7), 3);
|
||||
var live = new games.dmg.fantasystick.crafting.CharacterFactory().create(rules);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
storage.create(live).join();
|
||||
var awarded = storage.awardExperience(live.id(), live.revision(), BigInteger.valueOf(8), rules).join();
|
||||
assertEquals(CharacterStorage.ExperienceAwardStatus.APPLIED, awarded.status());
|
||||
assertEquals(1, awarded.levels());
|
||||
var levelTwo = awarded.record().orElseThrow();
|
||||
assertEquals(2, levelTwo.level());
|
||||
assertEquals(BigInteger.valueOf(8), levelTwo.experience());
|
||||
assertEquals(BigInteger.valueOf(3), levelTwo.unspentPoints());
|
||||
var capped = storage.awardExperience(live.id(), levelTwo.revision(), BigInteger.valueOf(100), rules).join();
|
||||
assertEquals(1, capped.levels());
|
||||
assertEquals(3, capped.record().orElseThrow().level());
|
||||
assertEquals(BigInteger.valueOf(14), capped.record().orElseThrow().experience());
|
||||
assertEquals(CharacterStorage.ExperienceAwardStatus.AT_CAP,
|
||||
storage.awardExperience(live.id(), capped.record().orElseThrow().revision(), BigInteger.ONE, rules).join().status());
|
||||
assertEquals(CharacterStorage.ExperienceAwardStatus.STALE,
|
||||
storage.awardExperience(live.id(), live.revision(), BigInteger.ONE, rules).join().status());
|
||||
var lowered = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.valueOf(7), 2);
|
||||
assertEquals(CharacterStorage.ExperienceAwardStatus.AT_CAP,
|
||||
storage.awardExperience(live.id(), capped.record().orElseThrow().revision(), BigInteger.ONE, lowered).join().status());
|
||||
assertEquals(capped.record().orElseThrow(), repository.load(live.id()).orElseThrow(),
|
||||
"Lowering configuration must not rewrite existing transferable progress");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void combatAwardsAtomicallyPersistXpPointsAndPerWorldRestrictedKillHistory() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var progression = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.TEN, 30);
|
||||
var rewards = games.dmg.fantasystick.progression.CombatRewardRules.defaults();
|
||||
var live = new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);
|
||||
var worldA = UUID.randomUUID(); var worldB = UUID.randomUUID();
|
||||
var first = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
|
||||
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
|
||||
var near = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
|
||||
java.math.BigDecimal.valueOf(2), java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
|
||||
var farFromUpdated = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
|
||||
java.math.BigDecimal.valueOf(5), java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
storage.create(live).join();
|
||||
var applied = storage.awardCombatExperience(live.id(), live.revision(), BigInteger.TEN,
|
||||
progression, rewards, first).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, applied.status());
|
||||
assertEquals(1, applied.levels());
|
||||
assertEquals(first, applied.record().orElseThrow().restrictedKills().get(worldA));
|
||||
|
||||
var blocked = storage.awardCombatExperience(live.id(), applied.record().orElseThrow().revision(), BigInteger.TEN,
|
||||
progression, rewards, near).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.SPACING_BLOCKED, blocked.status());
|
||||
assertEquals(applied.record().orElseThrow().experience(), blocked.record().orElseThrow().experience());
|
||||
assertEquals(near, blocked.record().orElseThrow().restrictedKills().get(worldA), "Blocked kills still update history");
|
||||
|
||||
var second = storage.awardCombatExperience(live.id(), blocked.record().orElseThrow().revision(), BigInteger.TEN,
|
||||
progression, rewards, farFromUpdated).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, second.status());
|
||||
assertEquals(BigInteger.valueOf(20), second.record().orElseThrow().experience());
|
||||
var otherWorld = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldB,
|
||||
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
|
||||
var firstThere = storage.awardCombatExperience(live.id(), second.record().orElseThrow().revision(), BigInteger.TEN,
|
||||
progression, rewards, otherWorld).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, firstThere.status());
|
||||
assertEquals(2, firstThere.record().orElseThrow().restrictedKills().size());
|
||||
|
||||
var passive = storage.awardCombatExperience(live.id(), firstThere.record().orElseThrow().revision(), BigInteger.ONE,
|
||||
progression, rewards, null).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, passive.status());
|
||||
assertEquals(firstThere.record().orElseThrow().restrictedKills(), passive.record().orElseThrow().restrictedKills());
|
||||
var lowered = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.valueOf(1000), 1);
|
||||
var thirdWorld = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(UUID.randomUUID(),
|
||||
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
|
||||
var cappedHistory = storage.awardCombatExperience(live.id(), passive.record().orElseThrow().revision(), BigInteger.TEN,
|
||||
lowered, rewards, thirdWorld).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.AT_CAP, cappedHistory.status());
|
||||
assertEquals(passive.record().orElseThrow().experience(), cappedHistory.record().orElseThrow().experience());
|
||||
assertEquals(passive.record().orElseThrow().level(), cappedHistory.record().orElseThrow().level());
|
||||
assertEquals(thirdWorld, cappedHistory.record().orElseThrow().restrictedKills().get(thirdWorld.world()));
|
||||
assertEquals(cappedHistory.record().orElseThrow(), repository.load(live.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void 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 abilityUnlearnRefundsExactlyOnePointClearsEverySlotAndPreservesCooldown() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var deadline = java.time.Instant.parse("2026-09-21T12:00:00Z");
|
||||
var learned = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
|
||||
base.experience(), BigInteger.valueOf(39), base.health(), java.util.Set.of("fireball"),
|
||||
java.util.List.of("fireball", "", "fireball", "", "", "", ""), java.util.Map.of("fireball", deadline),
|
||||
base.restrictedKills(), false, null, null);
|
||||
repository.create(learned);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var refunded = storage.unlearnAbility(learned.id(), learned.revision(), "fireball").join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, refunded.status());
|
||||
var saved = refunded.record().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), saved.unspentPoints());
|
||||
assertFalse(saved.learned().contains("fireball"));
|
||||
assertTrue(saved.loadout().stream().noneMatch("fireball"::equals));
|
||||
assertEquals(deadline, saved.cooldowns().get("fireball"));
|
||||
assertEquals(saved, repository.load(saved.id()).orElseThrow());
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.NOT_LEARNED,
|
||||
storage.unlearnAbility(saved.id(), saved.revision(), "fireball").join().status());
|
||||
assertEquals(saved, repository.load(saved.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 failedAbilityRefundLeavesLearnedStateAndPointsUnchangedForOneRetry() 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(), BigInteger.valueOf(39), base.health(), java.util.Set.of("fireball"),
|
||||
java.util.List.of("fireball", "", "", "", "", "", ""),
|
||||
java.util.Map.of("fireball", java.time.Instant.parse("2026-09-22T00:00:00Z")),
|
||||
base.restrictedKills(), false, null, null);
|
||||
repository.create(learned);
|
||||
var failing = new YamlCharacterRepository(data, (source, target) -> { throw new IOException("simulated refund failure"); });
|
||||
try (var storage = new CharacterStorage(failing, Clock.systemUTC())) {
|
||||
assertThrows(CompletionException.class,
|
||||
() -> storage.unlearnAbility(learned.id(), learned.revision(), "fireball").join());
|
||||
}
|
||||
assertEquals(learned, repository.load(learned.id()).orElseThrow());
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var refund = storage.unlearnAbility(learned.id(), learned.revision(), "fireball").join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, refund.status());
|
||||
assertEquals(BigInteger.valueOf(40), refund.record().orElseThrow().unspentPoints());
|
||||
assertEquals(CharacterStorage.StatPurchaseStatus.STALE,
|
||||
storage.purchaseStat(learned.id(), learned.revision(), games.dmg.fantasystick.progression.Stat.STR,
|
||||
learned.health(), new java.math.BigDecimal("1024")).join().status());
|
||||
}
|
||||
}
|
||||
|
||||
@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 nativeReviveBoundsFullHealthWithoutCappingTransferableCon() throws Exception {
|
||||
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.CON, BigInteger.valueOf(5000));
|
||||
var death = UUID.randomUUID(); var died = java.time.Instant.parse("2026-09-20T08:00:00Z");
|
||||
var dead = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
|
||||
base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(), base.cooldowns(),
|
||||
base.restrictedKills(), true, death, died);
|
||||
var repository = new YamlCharacterRepository(data); repository.create(dead);
|
||||
try (var storage = new CharacterStorage(repository,
|
||||
Clock.fixed(java.time.Instant.parse("2026-09-20T09:00:00Z"), ZoneOffset.UTC))) {
|
||||
var restored = storage.restoreDeathRecord(dead.id(), death, new java.math.BigDecimal("1024")).join().orElseThrow();
|
||||
assertEquals(new java.math.BigDecimal("1024"), restored.health());
|
||||
assertEquals(BigInteger.valueOf(5000), restored.stats().get(games.dmg.fantasystick.progression.Stat.CON));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRevivalSaveLeavesDeadStateRetryableAndShutdownRejectsNewWork() throws Exception {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class FrostShardSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesConcurrentAcceptedKeysWithoutLosingUnrelatedConfiguration() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "adventure-mode: true\nunrelated: retained\n");
|
||||
try (var settings = new FrostShardSettings(data, defaults())) {
|
||||
var damage = settings.set(FrostShardSettings.BASE_DAMAGE, "9").toCompletableFuture();
|
||||
var range = settings.set(FrostShardSettings.RANGE, "40.5").toCompletableFuture();
|
||||
var savedDamage = damage.get(5, TimeUnit.SECONDS);
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS);
|
||||
assertEquals("9", savedDamage.value()); assertEquals("40.5", savedRange.value());
|
||||
assertEquals(new java.math.BigInteger("4"), settings.settings().baseDamage(),
|
||||
"Durability acknowledgment alone must not publish runtime settings");
|
||||
settings.publish(savedRange); settings.publish(savedDamage); // stale callback cannot roll back the newer snapshot.
|
||||
assertEquals(new java.math.BigInteger("9"), settings.settings().baseDamage());
|
||||
assertEquals(40.5, settings.settings().range());
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals(Boolean.TRUE, saved.get("adventure-mode"));
|
||||
assertEquals("retained", saved.get("unrelated"));
|
||||
assertEquals("9", String.valueOf(saved.get(FrostShardSettings.BASE_DAMAGE)));
|
||||
assertEquals("40.5", String.valueOf(saved.get(FrostShardSettings.RANGE)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsMalformedAndOutOfRangeValuesBeforeQueueingOrChangingEffectiveState() {
|
||||
try (var settings = new FrostShardSettings(data, defaults())) {
|
||||
for (var entry : Map.of(
|
||||
FrostShardSettings.BASE_DAMAGE, "-1",
|
||||
FrostShardSettings.COOLDOWN, "31536001",
|
||||
FrostShardSettings.RANGE, "0",
|
||||
FrostShardSettings.LIFETIME, "0",
|
||||
FrostShardSettings.SPEED, "Infinity",
|
||||
FrostShardSettings.AMPLIFIER, "256",
|
||||
FrostShardSettings.SLOWNESS, "0").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(FrostShardSettings.BASE_DAMAGE, "1.5"));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(FrostShardSettings.COOLDOWN, "0.0000000001"));
|
||||
assertEquals("4", settings.value(FrostShardSettings.BASE_DAMAGE));
|
||||
assertFalse(Files.exists(data.resolve("config.yml")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAtomicReplacementRetainsEffectiveAndFileStateAndAllowsOneExplicitRetry() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "frost-shard-base-damage: 4\n");
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new FrostShardSettings(data, defaults(), (from, to) -> {
|
||||
if (fail.getAndSet(false)) throw new java.io.IOException("simulated replacement failure");
|
||||
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
|
||||
})) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(FrostShardSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("4", settings.value(FrostShardSettings.BASE_DAMAGE));
|
||||
assertTrue(Files.readString(data.resolve("config.yml")).contains("4"));
|
||||
var retry = settings.set(FrostShardSettings.BASE_DAMAGE, "9").toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
assertEquals("9", retry.value());
|
||||
assertEquals("4", settings.value(FrostShardSettings.BASE_DAMAGE));
|
||||
settings.publish(retry);
|
||||
assertEquals("9", settings.value(FrostShardSettings.BASE_DAMAGE));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsUnsafeConfigurationAndLockFilesAndPostCloseWrites() throws Exception {
|
||||
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||
var external = data.resolveSibling(data.getFileName() + "-outside");
|
||||
Files.writeString(external, "safe\n");
|
||||
Files.createSymbolicLink(data.resolve("config.yml"), external);
|
||||
try (var settings = new FrostShardSettings(data, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(FrostShardSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(external));
|
||||
}
|
||||
Files.delete(data.resolve("config.yml")); Files.deleteIfExists(external);
|
||||
}
|
||||
var settings = new FrostShardSettings(data, defaults()); settings.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(FrostShardSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String, String> defaults() {
|
||||
var result = new LinkedHashMap<String, String>();
|
||||
for (String key : FrostShardSettings.keys()) result.put(key, FrostShardSettings.defaultValue(key));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class LongbowSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesConcurrentAcceptedKeysWithoutLosingUnrelatedConfiguration() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "adventure-mode: true\nunrelated: retained\n");
|
||||
try (var settings = new LongbowSettings(data, defaults())) {
|
||||
var damage = settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture();
|
||||
var range = settings.set(LongbowSettings.RANGE, "40.5").toCompletableFuture();
|
||||
var savedDamage = damage.get(5, TimeUnit.SECONDS);
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS);
|
||||
assertEquals("9", savedDamage.value()); assertEquals("40.5", savedRange.value());
|
||||
assertEquals(new java.math.BigInteger("5"), settings.settings().baseDamage(),
|
||||
"Durability acknowledgment alone must not publish runtime settings");
|
||||
settings.publish(savedRange); settings.publish(savedDamage); // stale callback cannot roll back the newer snapshot.
|
||||
assertEquals(new java.math.BigInteger("9"), settings.settings().baseDamage());
|
||||
assertEquals(40.5, settings.settings().range());
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals(Boolean.TRUE, saved.get("adventure-mode"));
|
||||
assertEquals("retained", saved.get("unrelated"));
|
||||
assertEquals("9", String.valueOf(saved.get(LongbowSettings.BASE_DAMAGE)));
|
||||
assertEquals("40.5", String.valueOf(saved.get(LongbowSettings.RANGE)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsMalformedAndOutOfRangeValuesBeforeQueueingOrChangingEffectiveState() {
|
||||
try (var settings = new LongbowSettings(data, defaults())) {
|
||||
for (var entry : Map.of(
|
||||
LongbowSettings.BASE_DAMAGE, "-1",
|
||||
LongbowSettings.RECOVERY, "0.999",
|
||||
LongbowSettings.MINIMUM_DRAW, "0",
|
||||
LongbowSettings.FULL_DRAW, "2",
|
||||
LongbowSettings.RANGE, "0",
|
||||
LongbowSettings.LIFETIME, "0",
|
||||
LongbowSettings.FULL_SPEED, "Infinity").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(LongbowSettings.BASE_DAMAGE, "1.5"));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(LongbowSettings.BASE_DAMAGE, "1" + "0".repeat(309)));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(LongbowSettings.RECOVERY, "1.0000000001"));
|
||||
assertEquals("5", settings.value(LongbowSettings.BASE_DAMAGE));
|
||||
assertFalse(Files.exists(data.resolve("config.yml")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAtomicReplacementRetainsEffectiveAndFileStateAndAllowsOneExplicitRetry() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "longbow-base-damage: 5\n");
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new LongbowSettings(data, defaults(), (from, to) -> {
|
||||
if (fail.getAndSet(false)) throw new java.io.IOException("simulated replacement failure");
|
||||
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
|
||||
})) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("5", settings.value(LongbowSettings.BASE_DAMAGE));
|
||||
assertTrue(Files.readString(data.resolve("config.yml")).contains("5"));
|
||||
var retry = settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
assertEquals("9", retry.value());
|
||||
assertEquals("5", settings.value(LongbowSettings.BASE_DAMAGE));
|
||||
settings.publish(retry);
|
||||
assertEquals("9", settings.value(LongbowSettings.BASE_DAMAGE));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsUnsafeConfigurationAndLockFilesAndPostCloseWrites() throws Exception {
|
||||
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||
var external = data.resolveSibling(data.getFileName() + "-outside");
|
||||
Files.writeString(external, "safe\n");
|
||||
Files.createSymbolicLink(data.resolve("config.yml"), external);
|
||||
try (var settings = new LongbowSettings(data, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(external));
|
||||
}
|
||||
Files.delete(data.resolve("config.yml")); Files.deleteIfExists(external);
|
||||
}
|
||||
var settings = new LongbowSettings(data, defaults()); settings.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String, String> defaults() {
|
||||
var result = new LinkedHashMap<String, String>();
|
||||
for (String key : LongbowSettings.keys()) result.put(key, LongbowSettings.defaultValue(key));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class QuickstepSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesOnlyAcknowledgedSnapshots() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "adventure-mode: true\nunrelated: retained\n");
|
||||
try (var settings = new QuickstepSettings(data, defaults())) {
|
||||
var distance = settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture();
|
||||
var speed = settings.set(QuickstepSettings.SPEED, "1.5").toCompletableFuture();
|
||||
var savedDistance = distance.get(5, TimeUnit.SECONDS); var savedSpeed = speed.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(4, settings.settings().distance(), 0);
|
||||
settings.publish(savedSpeed); settings.publish(savedDistance);
|
||||
assertEquals(6, settings.settings().distance(), 0); assertEquals(1.5, settings.settings().speedPerTick(), 0);
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals(Boolean.TRUE, saved.get("adventure-mode")); assertEquals("retained", saved.get("unrelated"));
|
||||
assertEquals("6", String.valueOf(saved.get(QuickstepSettings.DISTANCE)));
|
||||
assertEquals("1.5", String.valueOf(saved.get(QuickstepSettings.SPEED)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsMalformedOrOutOfRangeValuesBeforeQueueing() {
|
||||
try (var settings = new QuickstepSettings(data, defaults())) {
|
||||
for (var entry : Map.of(QuickstepSettings.COOLDOWN, "-1", QuickstepSettings.DISTANCE, "0",
|
||||
QuickstepSettings.SPEED, "Infinity").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(QuickstepSettings.COOLDOWN, "0.0000000001"));
|
||||
assertEquals("4", settings.value(QuickstepSettings.DISTANCE));
|
||||
assertFalse(Files.exists(data.resolve("config.yml")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReplacementAndUnsafeFilesRetainEffectiveStateAndAllowRetry() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "quickstep-distance-blocks: 4\n");
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new QuickstepSettings(data, defaults(), (from, to) -> {
|
||||
if (fail.getAndSet(false)) throw new java.io.IOException("simulated failure");
|
||||
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
|
||||
})) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("4", settings.value(QuickstepSettings.DISTANCE));
|
||||
var retry = settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals("4", settings.value(QuickstepSettings.DISTANCE)); settings.publish(retry);
|
||||
assertEquals("6", settings.value(QuickstepSettings.DISTANCE));
|
||||
}
|
||||
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||
var external = data.resolveSibling(data.getFileName() + "-outside"); Files.writeString(external, "safe\n");
|
||||
Files.delete(data.resolve("config.yml")); Files.createSymbolicLink(data.resolve("config.yml"), external);
|
||||
try (var settings = new QuickstepSettings(data, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(QuickstepSettings.DISTANCE, "7").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(external));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void closeRejectsNewWritesAndCompletesDrain() throws Exception {
|
||||
var settings = new QuickstepSettings(data, defaults()); settings.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String, String> defaults() {
|
||||
var result = new LinkedHashMap<String, String>();
|
||||
for (String key : QuickstepSettings.keys()) result.put(key, QuickstepSettings.defaultValue(key));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class RevivalStoreTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void oneReservationRoundTripsAcrossReopenAndClearsOnlyWithItsToken() throws Exception {
|
||||
var value = reservation();
|
||||
try (var store = new RevivalStore(data)) {
|
||||
assertEquals(value, store.reserve(value).join().orElseThrow());
|
||||
assertTrue(store.reserve(new RevivalStore.Reservation(UUID.randomUUID(), value.casterCharacter(),
|
||||
value.targetPlayer(), value.character(), value.death(), value.createdAt(), value.sourceSlot(),
|
||||
value.sourceAmount(), value.destinationSlot())).join().isEmpty());
|
||||
assertEquals(value, store.find(value.character()).join().orElseThrow());
|
||||
assertEquals(java.util.List.of(value), store.target(value.targetPlayer()).join());
|
||||
assertFalse(store.clear(value.character(), UUID.randomUUID()).join());
|
||||
}
|
||||
try (var reopened = new RevivalStore(data)) {
|
||||
assertEquals(value, reopened.find(value.character()).join().orElseThrow());
|
||||
assertTrue(reopened.clear(value.character(), value.id()).join());
|
||||
assertTrue(reopened.find(value.character()).join().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void malformedOversizedAndSymlinkedReservationStateIsRetainedAndRejected() throws Exception {
|
||||
var value = reservation();
|
||||
try (var store = new RevivalStore(data)) { store.reserve(value).join(); }
|
||||
Path file = data.resolve("revivals").resolve(value.character() + ".yml");
|
||||
String valid = Files.readString(file);
|
||||
Files.writeString(file, valid + "unknown: value\n");
|
||||
try (var store = new RevivalStore(data)) {
|
||||
assertThrows(CompletionException.class, () -> store.find(value.character()).join());
|
||||
}
|
||||
assertTrue(Files.readString(file).endsWith("unknown: value\n"));
|
||||
Files.delete(file);
|
||||
Path outside = data.resolve("outside.yml"); Files.writeString(outside, valid);
|
||||
try {
|
||||
Files.createSymbolicLink(file, outside);
|
||||
try (var store = new RevivalStore(data)) {
|
||||
assertThrows(CompletionException.class, () -> store.find(value.character()).join());
|
||||
}
|
||||
} catch (UnsupportedOperationException ignored) { }
|
||||
}
|
||||
|
||||
private static RevivalStore.Reservation reservation() {
|
||||
return new RevivalStore.Reservation(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(),
|
||||
UUID.randomUUID(), Instant.parse("2026-09-20T10:00:00Z"), 10, 2, 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class SecondWindSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesConcurrentWritesPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new SecondWindSettings(data, defaults())) {
|
||||
var healing = settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture();
|
||||
var cooldown = settings.set(SecondWindSettings.COOLDOWN, "20").toCompletableFuture();
|
||||
var h = healing.get(5, TimeUnit.SECONDS); var c = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals("6", settings.value(SecondWindSettings.BASE_HEALING)); settings.publish(c); settings.publish(h);
|
||||
assertEquals("9", settings.value(SecondWindSettings.BASE_HEALING));
|
||||
assertEquals(java.time.Duration.ofSeconds(20), settings.settings().cooldown());
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", saved.get("unrelated")); assertEquals("9", String.valueOf(saved.get(SecondWindSettings.BASE_HEALING)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsInvalidValuesAndFailedReplacementRetainsState() throws Exception {
|
||||
try (var settings = new SecondWindSettings(data, defaults())) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.BASE_HEALING, "-1"));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.BASE_HEALING, "1.5"));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.COOLDOWN, "-1"));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.COOLDOWN, "0.0000000001"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new SecondWindSettings(data, defaults(), (from, to) -> {
|
||||
if (fail.getAndSet(false)) throw new java.io.IOException("simulated");
|
||||
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
|
||||
})) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("6", settings.value(SecondWindSettings.BASE_HEALING));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsUnsafeFileAndPostCloseWrites() throws Exception {
|
||||
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||
var external = data.resolveSibling(data.getFileName() + "-outside"); Files.writeString(external, "safe\n");
|
||||
Files.createSymbolicLink(data.resolve("config.yml"), external);
|
||||
try (var settings = new SecondWindSettings(data, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(external));
|
||||
}
|
||||
}
|
||||
var other = data.resolve("closed"); var settings = new SecondWindSettings(other, defaults()); settings.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String, String> defaults() {
|
||||
var result = new LinkedHashMap<String, String>();
|
||||
for (String key : SecondWindSettings.keys()) result.put(key, SecondWindSettings.defaultValue(key));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class StoneSkinSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new StoneSkinSettings(data, defaults())) {
|
||||
var duration = settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture();
|
||||
var reduction = settings.set(StoneSkinSettings.REDUCTION, "40").toCompletableFuture();
|
||||
var d = duration.get(5, TimeUnit.SECONDS); var r = reduction.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(java.time.Duration.ofSeconds(4), settings.settings().duration());
|
||||
settings.publish(r); settings.publish(d);
|
||||
assertEquals(java.time.Duration.ofSeconds(6), settings.settings().duration());
|
||||
assertEquals(0, new java.math.BigDecimal("40").compareTo(settings.settings().reductionPercent()));
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", saved.get("unrelated"));
|
||||
assertEquals("6", String.valueOf(saved.get(StoneSkinSettings.DURATION)));
|
||||
assertEquals("40", String.valueOf(saved.get(StoneSkinSettings.REDUCTION)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsInvalidValuesAndFailedReplacementRetainsState() throws Exception {
|
||||
try (var settings = new StoneSkinSettings(data, defaults())) {
|
||||
for (var entry : Map.of(StoneSkinSettings.COOLDOWN, "-1", StoneSkinSettings.DURATION, "0",
|
||||
StoneSkinSettings.REDUCTION, "101").entrySet())
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(StoneSkinSettings.DURATION, "0.0000000001"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new StoneSkinSettings(data, defaults(), (from, to) -> {
|
||||
if (fail.getAndSet(false)) throw new java.io.IOException("simulated");
|
||||
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
|
||||
})) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("4", settings.value(StoneSkinSettings.DURATION));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unsafeFileAndClosedStoreFailSafely() throws Exception {
|
||||
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||
var external = data.resolveSibling(data.getFileName() + "-outside"); Files.writeString(external, "safe\n");
|
||||
Files.createSymbolicLink(data.resolve("config.yml"), external);
|
||||
try (var settings = new StoneSkinSettings(data, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(external));
|
||||
}
|
||||
}
|
||||
var directory = data.resolve("closed"); var settings = new StoneSkinSettings(directory, defaults()); settings.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String,String> defaults() {
|
||||
var result = new LinkedHashMap<String,String>();
|
||||
for (String key : StoneSkinSettings.keys()) result.put(key, StoneSkinSettings.defaultValue(key));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,29 @@ class YamlCharacterRepositoryTest {
|
||||
original.loadout(), original.cooldowns(), original.dead(), original.deathId(), original.diedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restrictedKillHistoryRoundTripsPerWorldAndOldRecordsRemainCompatible() throws Exception {
|
||||
var original = sample(UUID.randomUUID());
|
||||
var worldA = UUID.randomUUID(); var worldB = UUID.randomUUID();
|
||||
var history = Map.of(
|
||||
worldA, new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
|
||||
new BigDecimal("1.25"), new BigDecimal("-64"), new BigDecimal("3E+2")),
|
||||
worldB, new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldB,
|
||||
BigDecimal.ZERO, BigDecimal.ONE, BigDecimal.TEN));
|
||||
var withHistory = new CharacterRecord(original.id(), original.name(), original.revision(), original.stats(),
|
||||
original.level(), original.experience(), original.unspentPoints(), original.health(), original.learned(),
|
||||
original.loadout(), original.cooldowns(), history, original.dead(), original.deathId(), original.diedAt());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(withHistory);
|
||||
assertEquals(withHistory, repository.load(withHistory.id()).orElseThrow());
|
||||
String yaml = Files.readString(data.resolve("characters").resolve(withHistory.id() + ".yml"));
|
||||
assertTrue(yaml.contains("restricted-kills:"));
|
||||
assertTrue(yaml.contains(worldA.toString()));
|
||||
|
||||
String legacy = CharacterYaml.encode(original).replace("restricted-kills: []\n", "");
|
||||
assertEquals(Map.of(), CharacterYaml.decode(legacy).restrictedKills());
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedCreationAndAbandonmentDoNotAccumulatePerCharacterCoordinationFiles() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CombatContributionsTest {
|
||||
@Test void deduplicatesCharactersRefreshesTheirDeadlineAndConsumesATargetOnce() {
|
||||
var ledger = new CombatContributions(CombatRewardRules.defaults());
|
||||
var target = UUID.randomUUID(); var a = UUID.randomUUID(); var b = UUID.randomUUID();
|
||||
var start = Instant.parse("2026-09-19T20:00:00Z");
|
||||
ledger.accept(target, a, start);
|
||||
ledger.accept(target, b, start);
|
||||
ledger.accept(target, a, start.plusSeconds(60));
|
||||
assertEquals(Set.of(a), ledger.consume(target, start.plusSeconds(301)), "Repeated accepted damage refreshes only its character");
|
||||
assertEquals(Set.of(), ledger.consume(target, start.plusSeconds(301)), "One target is awarded once");
|
||||
}
|
||||
|
||||
@Test void invalidationAndCloseBoundMemoryAndRefuseLaterDamage() {
|
||||
var ledger = new CombatContributions(CombatRewardRules.defaults());
|
||||
var target = UUID.randomUUID(); var character = UUID.randomUUID();
|
||||
var now = Instant.parse("2026-09-19T20:00:00Z");
|
||||
ledger.accept(target, character, now); ledger.invalidate(target);
|
||||
assertEquals(Set.of(), ledger.consume(target, now));
|
||||
ledger.accept(target, character, now); ledger.close(); ledger.accept(target, character, now);
|
||||
assertEquals(Set.of(), ledger.consume(target, now));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CombatRewardRulesTest {
|
||||
@Test void poolsAndRoundedSharesPreserveApprovedArithmetic() {
|
||||
var rules = new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), new BigDecimal("3"));
|
||||
assertEquals(BigInteger.ONE, rules.pool(CombatRewardRules.Target.PASSIVE, 99));
|
||||
assertEquals(BigInteger.TEN, rules.pool(CombatRewardRules.Target.ORDINARY, 99));
|
||||
assertEquals(BigInteger.valueOf(100), rules.pool(CombatRewardRules.Target.BOSS, 99));
|
||||
assertEquals(BigInteger.valueOf(70), rules.pool(CombatRewardRules.Target.FANTASY_CHARACTER, 7));
|
||||
assertEquals(BigInteger.valueOf(4), rules.share(BigInteger.TEN, 3));
|
||||
assertEquals(BigInteger.ONE, rules.share(BigInteger.ONE, 3));
|
||||
assertEquals(BigInteger.valueOf(34), rules.share(BigInteger.valueOf(100), 3));
|
||||
assertEquals(BigInteger.valueOf(24), rules.share(BigInteger.valueOf(70), 3));
|
||||
}
|
||||
|
||||
@Test void contributionWindowIncludesExactFiveMinuteBoundaryAndRejectsFutureTimes() {
|
||||
var rules = CombatRewardRules.defaults();
|
||||
var hit = Instant.parse("2026-09-19T20:00:00Z");
|
||||
assertTrue(rules.contributes(hit, hit.plus(Duration.ofMinutes(5))));
|
||||
assertFalse(rules.contributes(hit, hit.plus(Duration.ofMinutes(5)).plusNanos(1)));
|
||||
assertFalse(rules.contributes(hit.plusSeconds(1), hit));
|
||||
}
|
||||
|
||||
@Test void restrictedSpacingIsPerWorldAndUsesStraightLineThreeDimensionalDistance() {
|
||||
var rules = CombatRewardRules.defaults();
|
||||
var a = UUID.randomUUID(); var b = UUID.randomUUID();
|
||||
var origin = new CombatRewardRules.KillPosition(a, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO);
|
||||
assertFalse(rules.spaced(origin, new CombatRewardRules.KillPosition(a, new BigDecimal("2.999"), BigDecimal.ZERO, BigDecimal.ZERO)));
|
||||
assertTrue(rules.spaced(origin, new CombatRewardRules.KillPosition(a, new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("2"))));
|
||||
assertTrue(rules.spaced(origin, new CombatRewardRules.KillPosition(b, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO)));
|
||||
}
|
||||
|
||||
@Test void rejectsInvalidRewardConfigurationAndArithmeticInputs() {
|
||||
assertAll(
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.valueOf(-1), BigInteger.TEN, BigInteger.TEN, BigDecimal.ONE)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.TEN, new BigDecimal("-0.1"))),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> CombatRewardRules.defaults().share(BigInteger.ONE, 0)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> CombatRewardRules.defaults().pool(CombatRewardRules.Target.FANTASY_CHARACTER, 0)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules.KillPosition(UUID.randomUUID(), new BigDecimal("1E1000000"), BigDecimal.ZERO, BigDecimal.ZERO))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user