Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0536d4c717 | ||
|
|
079555900c | ||
|
|
1f3a1fe396 | ||
|
|
139cc4519b | ||
|
|
184d5520b2 | ||
|
|
16087bdfec | ||
|
|
bc8fe44033 | ||
|
|
4517aa3a64 | ||
|
|
89a57167cc | ||
|
|
9f5fede5e5 | ||
|
|
18b174609d | ||
|
|
dfa02bc335 | ||
|
|
4c2340d02b | ||
|
|
d890fb8915 | ||
|
|
683c4ed312 | ||
|
|
c80c088945 | ||
|
|
cc6c1a7f44 | ||
|
|
4abaeecf63 | ||
|
|
5a22dc343a |
No files matched your search
@@ -68,6 +68,25 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.ability.TemporaryHealth temporaryHealth;
|
||||
private games.dmg.fantasystick.ability.Inspire inspire;
|
||||
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
|
||||
private games.dmg.fantasystick.ability.MendingLight mendingLight;
|
||||
private games.dmg.fantasystick.persistence.MendingLightSettings mendingLightSettings;
|
||||
private games.dmg.fantasystick.ability.Purify purify;
|
||||
private games.dmg.fantasystick.persistence.PurifySettings purifySettings;
|
||||
private games.dmg.fantasystick.ability.GuardianBond guardianBond;
|
||||
private games.dmg.fantasystick.persistence.GuardianBondSettings guardianBondSettings;
|
||||
private games.dmg.fantasystick.ability.OutgoingDamageBonus outgoingDamageBonus;
|
||||
private games.dmg.fantasystick.ability.RallyingCry rallyingCry;
|
||||
private games.dmg.fantasystick.persistence.RallyingCrySettings rallyingCrySettings;
|
||||
private games.dmg.fantasystick.ability.FrostNova frostNova;
|
||||
private games.dmg.fantasystick.persistence.FrostNovaSettings frostNovaSettings;
|
||||
private games.dmg.fantasystick.ability.ChainLightning chainLightning;
|
||||
private games.dmg.fantasystick.persistence.ChainLightningSettings chainLightningSettings;
|
||||
private games.dmg.fantasystick.ability.Sanctuary sanctuary;
|
||||
private games.dmg.fantasystick.persistence.SanctuarySettings sanctuarySettings;
|
||||
private games.dmg.fantasystick.ability.WarBanner warBanner;
|
||||
private games.dmg.fantasystick.persistence.WarBannerSettings warBannerSettings;
|
||||
private games.dmg.fantasystick.ability.Meteor meteor;
|
||||
private games.dmg.fantasystick.persistence.MeteorSettings meteorSettings;
|
||||
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
|
||||
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
|
||||
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
|
||||
@@ -82,6 +101,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
|
||||
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
|
||||
private games.dmg.fantasystick.session.OutOfCombatRecovery outOfCombatRecovery;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationI quickerRegeneration;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationIISettings quickerRegenerationIISettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
|
||||
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
|
||||
private games.dmg.fantasystick.passive.HealthSteal healthSteal;
|
||||
private games.dmg.fantasystick.passive.SpeedI speedI;
|
||||
private games.dmg.fantasystick.passive.LowLightVision lowLightVision;
|
||||
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
|
||||
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@Override public void onEnable() {
|
||||
@@ -110,6 +141,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getConfig().get(games.dmg.fantasystick.persistence.RoleplaySettings.KEY));
|
||||
var partyMaximum = games.dmg.fantasystick.persistence.PartySettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.PartySettings.KEY));
|
||||
var recoveryConfiguration = outOfCombatRecoveryConfiguration();
|
||||
var recoveryInitial = games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.parse(recoveryConfiguration);
|
||||
var quickerRegenerationInitial = games.dmg.fantasystick.persistence.QuickerRegenerationSettings.parse(
|
||||
getConfig().getString(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY,
|
||||
games.dmg.fantasystick.persistence.QuickerRegenerationSettings.DEFAULT));
|
||||
var quickerRegenerationIIInitial = games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.parse(
|
||||
getConfig().getString(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY,
|
||||
games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.DEFAULT));
|
||||
var healthStealInitial = games.dmg.fantasystick.persistence.HealthStealSettings.parse(
|
||||
getConfig().getString(games.dmg.fantasystick.persistence.HealthStealSettings.KEY,
|
||||
games.dmg.fantasystick.persistence.HealthStealSettings.DEFAULT));
|
||||
var sheetPackDefaults = games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults();
|
||||
sheetResourcePackSettings = new games.dmg.fantasystick.persistence.SheetResourcePackSettings(getDataFolder().toPath(),
|
||||
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY),
|
||||
@@ -134,6 +176,24 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var hexInitial = games.dmg.fantasystick.persistence.HexSettings.parse(hexConfiguration);
|
||||
var inspireConfiguration = inspireConfiguration();
|
||||
var inspireInitial = games.dmg.fantasystick.persistence.InspireSettings.parse(inspireConfiguration);
|
||||
var mendingLightConfiguration = mendingLightConfiguration();
|
||||
var mendingLightInitial = games.dmg.fantasystick.persistence.MendingLightSettings.parse(mendingLightConfiguration);
|
||||
var purifyConfiguration = purifyConfiguration();
|
||||
var purifyInitial = games.dmg.fantasystick.persistence.PurifySettings.parse(purifyConfiguration);
|
||||
var guardianBondConfiguration = guardianBondConfiguration();
|
||||
var guardianBondInitial = games.dmg.fantasystick.persistence.GuardianBondSettings.parse(guardianBondConfiguration);
|
||||
var rallyingCryConfiguration = rallyingCryConfiguration();
|
||||
var rallyingCryInitial = games.dmg.fantasystick.persistence.RallyingCrySettings.parse(rallyingCryConfiguration);
|
||||
var frostNovaConfiguration = frostNovaConfiguration();
|
||||
var frostNovaInitial = games.dmg.fantasystick.persistence.FrostNovaSettings.parse(frostNovaConfiguration);
|
||||
var chainLightningConfiguration = chainLightningConfiguration();
|
||||
var chainLightningInitial = games.dmg.fantasystick.persistence.ChainLightningSettings.parse(chainLightningConfiguration);
|
||||
var sanctuaryConfiguration = sanctuaryConfiguration();
|
||||
var sanctuaryInitial = games.dmg.fantasystick.persistence.SanctuarySettings.parse(sanctuaryConfiguration);
|
||||
var warBannerConfiguration = warBannerConfiguration();
|
||||
var warBannerInitial = games.dmg.fantasystick.persistence.WarBannerSettings.parse(warBannerConfiguration);
|
||||
var meteorConfiguration = meteorConfiguration();
|
||||
var meteorInitial = games.dmg.fantasystick.persistence.MeteorSettings.parse(meteorConfiguration);
|
||||
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
|
||||
var factory = new CharacterFactory();
|
||||
@@ -223,6 +283,42 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
|
||||
inspireSettings = new games.dmg.fantasystick.persistence.InspireSettings(getDataFolder().toPath(), inspireConfiguration);
|
||||
inspire = new games.dmg.fantasystick.ability.Inspire(inspireInitial, temporaryHealth);
|
||||
mendingLightSettings = new games.dmg.fantasystick.persistence.MendingLightSettings(
|
||||
getDataFolder().toPath(), mendingLightConfiguration);
|
||||
mendingLight = new games.dmg.fantasystick.ability.MendingLight(mendingLightInitial, characters, sessions, parties,
|
||||
nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
|
||||
purifySettings = new games.dmg.fantasystick.persistence.PurifySettings(getDataFolder().toPath(), purifyConfiguration);
|
||||
purify = new games.dmg.fantasystick.ability.Purify(purifyInitial, sessions, parties,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
|
||||
guardianBondSettings = new games.dmg.fantasystick.persistence.GuardianBondSettings(
|
||||
getDataFolder().toPath(), guardianBondConfiguration);
|
||||
guardianBond = new games.dmg.fantasystick.ability.GuardianBond(guardianBondInitial, sessions, parties,
|
||||
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
|
||||
outgoingDamageBonus = new games.dmg.fantasystick.ability.OutgoingDamageBonus(sessions, parties,
|
||||
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
|
||||
rallyingCrySettings = new games.dmg.fantasystick.persistence.RallyingCrySettings(
|
||||
getDataFolder().toPath(), rallyingCryConfiguration);
|
||||
rallyingCry = new games.dmg.fantasystick.ability.RallyingCry(
|
||||
rallyingCryInitial, sessions, parties, outgoingDamageBonus);
|
||||
frostNovaSettings = new games.dmg.fantasystick.persistence.FrostNovaSettings(
|
||||
getDataFolder().toPath(), frostNovaConfiguration);
|
||||
frostNova = new games.dmg.fantasystick.ability.FrostNova(frostNovaInitial, sessions, parties);
|
||||
chainLightningSettings = new games.dmg.fantasystick.persistence.ChainLightningSettings(
|
||||
getDataFolder().toPath(), chainLightningConfiguration);
|
||||
chainLightning = new games.dmg.fantasystick.ability.ChainLightning(chainLightningInitial, sessions, parties);
|
||||
sanctuarySettings = new games.dmg.fantasystick.persistence.SanctuarySettings(
|
||||
getDataFolder().toPath(), sanctuaryConfiguration);
|
||||
sanctuary = new games.dmg.fantasystick.ability.Sanctuary(sanctuaryInitial, characters, sessions, parties,
|
||||
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
|
||||
warBannerSettings = new games.dmg.fantasystick.persistence.WarBannerSettings(
|
||||
getDataFolder().toPath(), warBannerConfiguration);
|
||||
warBanner = new games.dmg.fantasystick.ability.WarBanner(warBannerInitial, sessions, parties, outgoingDamageBonus);
|
||||
meteorSettings = new games.dmg.fantasystick.persistence.MeteorSettings(getDataFolder().toPath(), meteorConfiguration);
|
||||
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties);
|
||||
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,
|
||||
@@ -237,12 +333,58 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityRegistry.register(shieldBash.definition());
|
||||
abilityRegistry.register(hex.definition());
|
||||
abilityRegistry.register(inspire.definition());
|
||||
abilityRegistry.register(mendingLight.definition());
|
||||
abilityRegistry.register(purify.definition());
|
||||
abilityRegistry.register(guardianBond.definition());
|
||||
abilityRegistry.register(rallyingCry.definition());
|
||||
abilityRegistry.register(frostNova.definition());
|
||||
abilityRegistry.register(chainLightning.definition());
|
||||
abilityRegistry.register(sanctuary.definition());
|
||||
abilityRegistry.register(warBanner.definition());
|
||||
abilityRegistry.register(meteor.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);
|
||||
outOfCombatRecoverySettings = new games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings(
|
||||
getDataFolder().toPath(), recoveryConfiguration);
|
||||
outOfCombatRecovery = new games.dmg.fantasystick.session.OutOfCombatRecovery(recoveryInitial, characters, sessions,
|
||||
Clock.systemUTC(), nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
problem -> getLogger().log(Level.WARNING,
|
||||
"Out-of-combat recovery failed for character " + problem.character(), problem.cause()));
|
||||
quickerRegenerationSettings = new games.dmg.fantasystick.persistence.QuickerRegenerationSettings(
|
||||
getDataFolder().toPath(), quickerRegenerationInitial.bonus().toString());
|
||||
quickerRegeneration = new games.dmg.fantasystick.passive.QuickerRegenerationI(quickerRegenerationInitial);
|
||||
quickerRegenerationIISettings = new games.dmg.fantasystick.persistence.QuickerRegenerationIISettings(
|
||||
getDataFolder().toPath(), quickerRegenerationIIInitial.bonus().toString());
|
||||
quickerRegenerationII = new games.dmg.fantasystick.passive.QuickerRegenerationII(quickerRegenerationIIInitial);
|
||||
healthStealSettings = new games.dmg.fantasystick.persistence.HealthStealSettings(
|
||||
getDataFolder().toPath(), healthStealInitial.healing().toString());
|
||||
healthSteal = new games.dmg.fantasystick.passive.HealthSteal(healthStealInitial, characters, sessions, nextTick,
|
||||
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()));
|
||||
speedI = new games.dmg.fantasystick.passive.SpeedI(sessions);
|
||||
lowLightVision = new games.dmg.fantasystick.passive.LowLightVision(sessions);
|
||||
passiveRegistry = new games.dmg.fantasystick.passive.PassiveRegistry();
|
||||
passiveRegistry.register(quickerRegeneration.definition());
|
||||
passiveRegistry.register(quickerRegenerationII.definition());
|
||||
passiveRegistry.register(healthSteal.definition());
|
||||
passiveRegistry.register(speedI.definition());
|
||||
passiveRegistry.register(lowLightVision.definition());
|
||||
passiveEngine = new games.dmg.fantasystick.passive.PassiveEngine(passiveRegistry, characters, sessions, nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Passive change failed for character " + problem.character()
|
||||
+ "; passive " + problem.passive(), problem.cause()));
|
||||
outOfCombatRecovery.attachBonusHealing(record -> {
|
||||
if (!record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)) {
|
||||
return java.math.BigInteger.ZERO;
|
||||
}
|
||||
var bonus = quickerRegeneration.bonus();
|
||||
return record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationII.ID)
|
||||
? bonus.add(quickerRegenerationII.bonus()) : bonus;
|
||||
});
|
||||
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);
|
||||
@@ -263,7 +405,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
String name = localPresentation.get().name(id);
|
||||
var player = getServer().getPlayer(id);
|
||||
return net.kyori.adventure.text.Component.text(name != null ? name : player == null ? id.toString() : player.getName());
|
||||
}, abilityRegistry, abilityEngine);
|
||||
}, abilityRegistry, abilityEngine, passiveRegistry, passiveEngine);
|
||||
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
|
||||
basicAttack = localBasicAttack;
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, input -> {
|
||||
@@ -279,6 +421,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
quickstepSettings, quickstep, secondWindSettings, secondWind,
|
||||
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
|
||||
inspireSettings, inspire, abilityRegistry);
|
||||
admin.attachMendingLight(mendingLightSettings, mendingLight);
|
||||
admin.attachPurify(purifySettings, purify);
|
||||
admin.attachGuardianBond(guardianBondSettings, guardianBond);
|
||||
admin.attachRallyingCry(rallyingCrySettings, rallyingCry);
|
||||
admin.attachFrostNova(frostNovaSettings, frostNova);
|
||||
admin.attachChainLightning(chainLightningSettings, chainLightning);
|
||||
admin.attachSanctuary(sanctuarySettings, sanctuary);
|
||||
admin.attachWarBanner(warBannerSettings, warBanner);
|
||||
admin.attachMeteor(meteorSettings, meteor);
|
||||
admin.attachAdminRevival(adminRevival, adminReviveSettings);
|
||||
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
|
||||
characters, rules, sessions, nextTick, getServer()::getPlayer,
|
||||
@@ -288,6 +439,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
admin.attachRoleplaySettings(roleplaySettings);
|
||||
admin.attachPartySettings(partySettings);
|
||||
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
|
||||
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
|
||||
admin.attachQuickerRegeneration(quickerRegenerationSettings, quickerRegeneration, passiveRegistry);
|
||||
admin.attachQuickerRegenerationII(quickerRegenerationIISettings, quickerRegenerationII, passiveRegistry);
|
||||
admin.attachHealthSteal(healthStealSettings, healthSteal, passiveRegistry);
|
||||
admin.attachCharacterIssuer(new games.dmg.fantasystick.session.AdminCharacterIssuer(characters,
|
||||
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
|
||||
"Admin character issue " + problem.operation() + " for player " + problem.player()
|
||||
@@ -303,21 +458,34 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(partyEvents, this);
|
||||
getServer().getPluginManager().registerEvents(partyCombat, this);
|
||||
partyCombat.install(this);
|
||||
getServer().getPluginManager().registerEvents(healthSteal, this);
|
||||
getServer().getPluginManager().registerEvents(combatExperience, this);
|
||||
combatExperience.install(this);
|
||||
getServer().getPluginManager().registerEvents(fireball, this);
|
||||
getServer().getPluginManager().registerEvents(frostShard, this);
|
||||
getServer().getPluginManager().registerEvents(frostNova, this);
|
||||
getServer().getPluginManager().registerEvents(chainLightning, this);
|
||||
getServer().getPluginManager().registerEvents(meteor, this);
|
||||
getServer().getPluginManager().registerEvents(longbow, this);
|
||||
getServer().getPluginManager().registerEvents(quickstep, this);
|
||||
getServer().getPluginManager().registerEvents(combatMitigation, this);
|
||||
getServer().getPluginManager().registerEvents(guardianBond, this);
|
||||
guardianBond.install(this);
|
||||
getServer().getPluginManager().registerEvents(shieldBash, this);
|
||||
getServer().getPluginManager().registerEvents(outgoingDamageMitigation, this);
|
||||
getServer().getPluginManager().registerEvents(outgoingDamageBonus, this);
|
||||
rallyingCry.install(this);
|
||||
warBanner.install(this);
|
||||
getServer().getPluginManager().registerEvents(temporaryHealth, this);
|
||||
getServer().getPluginManager().registerEvents(greatSword, this);
|
||||
getServer().getPluginManager().registerEvents(revive, this);
|
||||
getServer().getPluginManager().registerEvents(adminRevival, this);
|
||||
getServer().getPluginManager().registerEvents(localBasicAttack, this);
|
||||
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
|
||||
getServer().getPluginManager().registerEvents(outOfCombatRecovery, this);
|
||||
outOfCombatRecovery.install(this);
|
||||
speedI.install(this);
|
||||
lowLightVision.install(this);
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
getServer().getPluginManager().registerEvents(gameModes, this);
|
||||
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
|
||||
@@ -378,6 +546,25 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingHex = hex; hex = null;
|
||||
var closingInspire = inspire; inspire = null;
|
||||
var closingInspireSettings = inspireSettings; inspireSettings = null;
|
||||
mendingLight = null;
|
||||
var closingMendingLightSettings = mendingLightSettings; mendingLightSettings = null;
|
||||
purify = null;
|
||||
var closingPurifySettings = purifySettings; purifySettings = null;
|
||||
var closingGuardianBond = guardianBond; guardianBond = null;
|
||||
var closingGuardianBondSettings = guardianBondSettings; guardianBondSettings = null;
|
||||
var closingRallyingCry = rallyingCry; rallyingCry = null;
|
||||
var closingRallyingCrySettings = rallyingCrySettings; rallyingCrySettings = null;
|
||||
var closingFrostNova = frostNova; frostNova = null;
|
||||
var closingFrostNovaSettings = frostNovaSettings; frostNovaSettings = null;
|
||||
var closingChainLightning = chainLightning; chainLightning = null;
|
||||
var closingChainLightningSettings = chainLightningSettings; chainLightningSettings = null;
|
||||
var closingSanctuary = sanctuary; sanctuary = null;
|
||||
var closingSanctuarySettings = sanctuarySettings; sanctuarySettings = null;
|
||||
var closingWarBanner = warBanner; warBanner = null;
|
||||
var closingWarBannerSettings = warBannerSettings; warBannerSettings = null;
|
||||
var closingMeteor = meteor; meteor = null;
|
||||
var closingMeteorSettings = meteorSettings; meteorSettings = null;
|
||||
var closingOutgoingDamageBonus = outgoingDamageBonus; outgoingDamageBonus = null;
|
||||
var closingAdminRevival = adminRevival; adminRevival = null;
|
||||
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
|
||||
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
|
||||
@@ -397,6 +584,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingRevivals = revivals; revivals = null;
|
||||
abilityRegistry = null;
|
||||
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
|
||||
var closingOutOfCombatRecovery = outOfCombatRecovery; outOfCombatRecovery = null;
|
||||
var closingOutOfCombatRecoverySettings = outOfCombatRecoverySettings; outOfCombatRecoverySettings = null;
|
||||
var closingQuickerRegenerationSettings = quickerRegenerationSettings; quickerRegenerationSettings = null;
|
||||
var closingQuickerRegenerationIISettings = quickerRegenerationIISettings; quickerRegenerationIISettings = null;
|
||||
var closingHealthStealSettings = healthStealSettings; healthStealSettings = null;
|
||||
var closingHealthSteal = healthSteal; healthSteal = null;
|
||||
var closingSpeedI = speedI; speedI = null;
|
||||
var closingLowLightVision = lowLightVision; lowLightVision = null;
|
||||
var closingPassiveEngine = passiveEngine; passiveEngine = null;
|
||||
quickerRegeneration = null; quickerRegenerationII = null; passiveRegistry = 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);
|
||||
@@ -410,12 +607,33 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingShieldBashSettings != null) cleanup("Shield Bash configuration", closingShieldBashSettings::close);
|
||||
if (closingHexSettings != null) cleanup("Hex configuration", closingHexSettings::close);
|
||||
if (closingInspireSettings != null) cleanup("Inspire configuration", closingInspireSettings::close);
|
||||
if (closingMendingLightSettings != null) cleanup("Mending Light configuration", closingMendingLightSettings::close);
|
||||
if (closingPurifySettings != null) cleanup("Purify configuration", closingPurifySettings::close);
|
||||
if (closingGuardianBond != null) cleanup("Guardian Bond", closingGuardianBond::close);
|
||||
if (closingGuardianBondSettings != null) cleanup("Guardian Bond configuration", closingGuardianBondSettings::close);
|
||||
if (closingRallyingCry != null) cleanup("Rallying Cry", closingRallyingCry::close);
|
||||
if (closingRallyingCrySettings != null) cleanup("Rallying Cry configuration", closingRallyingCrySettings::close);
|
||||
if (closingFrostNova != null) cleanup("Frost Nova", closingFrostNova::close);
|
||||
if (closingFrostNovaSettings != null) cleanup("Frost Nova configuration", closingFrostNovaSettings::close);
|
||||
if (closingChainLightning != null) cleanup("Chain Lightning", closingChainLightning::close);
|
||||
if (closingChainLightningSettings != null) cleanup("Chain Lightning configuration", closingChainLightningSettings::close);
|
||||
if (closingSanctuary != null) cleanup("Sanctuary", closingSanctuary::close);
|
||||
if (closingSanctuarySettings != null) cleanup("Sanctuary configuration", closingSanctuarySettings::close);
|
||||
if (closingWarBanner != null) cleanup("War Banner", closingWarBanner::close);
|
||||
if (closingWarBannerSettings != null) cleanup("War Banner configuration", closingWarBannerSettings::close);
|
||||
if (closingMeteor != null) cleanup("Meteor", closingMeteor::close);
|
||||
if (closingMeteorSettings != null) cleanup("Meteor configuration", closingMeteorSettings::close);
|
||||
if (closingOutgoingDamageBonus != null) cleanup("outgoing damage bonuses", closingOutgoingDamageBonus::close);
|
||||
if (closingAdminReviveSettings != null) cleanup("admin revival configuration", closingAdminReviveSettings::close);
|
||||
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
|
||||
if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close);
|
||||
if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close);
|
||||
if (closingSheetResourcePackSettings != null) cleanup("sheet resource-pack configuration", closingSheetResourcePackSettings::close);
|
||||
if (closingSheetResourcePack != null) cleanup("sheet resource-pack", closingSheetResourcePack::close);
|
||||
if (closingOutOfCombatRecoverySettings != null) cleanup("out-of-combat recovery configuration", closingOutOfCombatRecoverySettings::close);
|
||||
if (closingQuickerRegenerationSettings != null) cleanup("Quicker Regeneration I configuration", closingQuickerRegenerationSettings::close);
|
||||
if (closingQuickerRegenerationIISettings != null) cleanup("Quicker Regeneration II configuration", closingQuickerRegenerationIISettings::close);
|
||||
if (closingHealthStealSettings != null) cleanup("Health Steal configuration", closingHealthStealSettings::close);
|
||||
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
|
||||
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSettings.drained().toCompletableFuture(),
|
||||
@@ -435,6 +653,24 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
: closingHexSettings.drained().toCompletableFuture(),
|
||||
closingInspireSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingInspireSettings.drained().toCompletableFuture(),
|
||||
closingMendingLightSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingMendingLightSettings.drained().toCompletableFuture(),
|
||||
closingPurifySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPurifySettings.drained().toCompletableFuture(),
|
||||
closingGuardianBondSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingGuardianBondSettings.drained().toCompletableFuture(),
|
||||
closingRallyingCrySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingRallyingCrySettings.drained().toCompletableFuture(),
|
||||
closingFrostNovaSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingFrostNovaSettings.drained().toCompletableFuture(),
|
||||
closingChainLightningSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingChainLightningSettings.drained().toCompletableFuture(),
|
||||
closingSanctuarySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSanctuarySettings.drained().toCompletableFuture(),
|
||||
closingWarBannerSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingWarBannerSettings.drained().toCompletableFuture(),
|
||||
closingMeteorSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingMeteorSettings.drained().toCompletableFuture(),
|
||||
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingAdminReviveSettings.drained().toCompletableFuture(),
|
||||
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -444,7 +680,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingPartySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPartySettings.drained().toCompletableFuture(),
|
||||
closingSheetResourcePackSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSheetResourcePackSettings.drained().toCompletableFuture());
|
||||
: closingSheetResourcePackSettings.drained().toCompletableFuture(),
|
||||
closingOutOfCombatRecoverySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingOutOfCombatRecoverySettings.drained().toCompletableFuture(),
|
||||
closingQuickerRegenerationSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingQuickerRegenerationSettings.drained().toCompletableFuture(),
|
||||
closingQuickerRegenerationIISettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingQuickerRegenerationIISettings.drained().toCompletableFuture(),
|
||||
closingHealthStealSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingHealthStealSettings.drained().toCompletableFuture());
|
||||
if (closingBooks != null) cleanup("character book", closingBooks::close);
|
||||
if (closingAdminRevival != null) cleanup("admin revival", closingAdminRevival::close);
|
||||
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
|
||||
@@ -464,6 +708,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingRevive != null) cleanup("Revive", closingRevive::close);
|
||||
if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close);
|
||||
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
|
||||
if (closingOutOfCombatRecovery != null) cleanup("out-of-combat recovery", closingOutOfCombatRecovery::close);
|
||||
if (closingPassiveEngine != null) cleanup("passive engine", closingPassiveEngine::close);
|
||||
if (closingHealthSteal != null) cleanup("Health Steal", closingHealthSteal::close);
|
||||
if (closingSpeedI != null) cleanup("Speed I", closingSpeedI::close);
|
||||
if (closingLowLightVision != null) cleanup("Low-Light Vision", closingLowLightVision::close);
|
||||
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
|
||||
if (closingParties != null) cleanup("parties", closingParties::close);
|
||||
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
|
||||
@@ -476,6 +725,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingPartyEvents);
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingCombatExperience);
|
||||
unregister(closingHealthSteal);
|
||||
unregister(closingFireball);
|
||||
unregister(closingFrostShard);
|
||||
unregister(closingLongbow);
|
||||
@@ -489,6 +739,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingAdminRevival);
|
||||
unregister(closingBasicAttack);
|
||||
unregister(closingNutrition);
|
||||
unregister(closingOutOfCombatRecovery);
|
||||
unregister(closingBooks);
|
||||
unregister(closingEvents);
|
||||
unregister(closingCrafting);
|
||||
@@ -558,6 +809,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> outOfCombatRecoveryConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> frostShardConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.FrostShardSettings.keys()) {
|
||||
@@ -636,6 +897,95 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> mendingLightConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.MendingLightSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.MendingLightSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.MendingLightSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> purifyConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.PurifySettings.keys()) {
|
||||
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.PurifySettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.PurifySettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> guardianBondConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.GuardianBondSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.GuardianBondSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.GuardianBondSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> rallyingCryConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.RallyingCrySettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.RallyingCrySettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.RallyingCrySettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> frostNovaConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.FrostNovaSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.FrostNovaSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.FrostNovaSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> chainLightningConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.ChainLightningSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.ChainLightningSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.ChainLightningSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> sanctuaryConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.SanctuarySettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.SanctuarySettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.SanctuarySettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> warBannerConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.WarBannerSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.WarBannerSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.WarBannerSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> meteorConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.MeteorSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.MeteorSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.MeteorSettings.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"));
|
||||
|
||||
@@ -221,6 +221,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
/** Bounded lifecycle sweep; plugin timers may call this without scanning future catalogues. */
|
||||
public void sweep() {
|
||||
if (closed) return;
|
||||
sessions.refreshCooldownControls(64);
|
||||
int checked = 0;
|
||||
outer: for (var runtimes : List.copyOf(active.values())) {
|
||||
for (var runtime : List.copyOf(runtimes)) {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
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.ArrayList;
|
||||
import java.util.Comparator;
|
||||
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 org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.Entity;
|
||||
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.EntityDamageEvent;
|
||||
|
||||
/** Deterministic bounded four-target lightning chain with exact decimal decay. */
|
||||
public final class ChainLightning implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double initialRange, double jumpRange,
|
||||
int additionalTargets, BigDecimal jumpMultiplier) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(jumpMultiplier);
|
||||
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(initialRange) || initialRange <= 0 || initialRange > 64
|
||||
|| !Double.isFinite(jumpRange) || jumpRange <= 0 || jumpRange > 64
|
||||
|| additionalTargets < 0 || additionalTargets > 63
|
||||
|| jumpMultiplier.signum() <= 0 || jumpMultiplier.compareTo(BigDecimal.ONE) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Chain Lightning settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(8), Duration.ofSeconds(18), 16, 6, 3, new BigDecimal("0.75"));
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private Attempt damaging;
|
||||
private boolean closed;
|
||||
|
||||
public ChainLightning(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("chain_lightning", "Chain Lightning", Material.LIGHTNING_ROD,
|
||||
List.of("Strike for " + current.baseDamage() + " + INT modifier damage, then jump up to "
|
||||
+ current.additionalTargets() + " times.",
|
||||
"Each jump deals " + decimal(current.jumpMultiplier().movePointRight(2))
|
||||
+ "% of the previous defined damage within " + decimal(BigDecimal.valueOf(current.jumpRange())) + " blocks."),
|
||||
"Use while aiming at the first visible hostile within " + decimal(BigDecimal.valueOf(current.initialRange())) + " blocks.",
|
||||
Map.of(Stat.INT, BigInteger.valueOf(20), Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()),
|
||||
"initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
|
||||
"jump-range", BigDecimal.valueOf(current.jumpRange()).stripTrailingZeros(),
|
||||
"additional-targets", BigDecimal.valueOf(current.additionalTargets()),
|
||||
"jump-multiplier", current.jumpMultiplier()), AbilityDefinition.TargetPolicy.SELECTION, this);
|
||||
}
|
||||
|
||||
public static BigInteger initialDamage(BigInteger intelligence) { return initialDamage(BigInteger.valueOf(8), intelligence); }
|
||||
static BigInteger initialDamage(BigInteger base, BigInteger intelligence) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
|
||||
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Chain Lightning damage input");
|
||||
var delta = intelligence.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
var modifier = divided[0]; if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
return !closed && selectInitial(input, settings.initialRange()) != null;
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var captured = from(execution.definition()); var caster = execution.input().player();
|
||||
var initial = selectInitial(execution.input(), captured.initialRange());
|
||||
if (initial == null) return;
|
||||
BigDecimal defined = new BigDecimal(initialDamage(captured.baseDamage(), execution.character().stats().get(Stat.INT)));
|
||||
var visited = new HashSet<UUID>(); LivingEntity current = initial;
|
||||
int maximum = Math.min(64, 1 + captured.additionalTargets());
|
||||
for (int strike = 0; strike < maximum && current != null && execution.stillCurrent(); strike++) {
|
||||
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
|
||||
strike(execution, caster, current, defined); visual(caster, current);
|
||||
if (strike + 1 == maximum) break;
|
||||
defined = defined.multiply(captured.jumpMultiplier());
|
||||
current = next(caster, current, visited, captured.jumpRange());
|
||||
}
|
||||
}
|
||||
|
||||
private void strike(AbilityExecution execution, Player caster, LivingEntity target, BigDecimal defined) {
|
||||
double amount = finite(definiteNonnegative(defined));
|
||||
var origin = caster.getEyeLocation().clone();
|
||||
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
|
||||
.withDirectEntity(caster).withDamageLocation(origin).build();
|
||||
var attempt = new Attempt(execution, target); damaging = attempt;
|
||||
try { target.damage(amount, source); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
}
|
||||
|
||||
private LivingEntity selectInitial(ControlInput input, double range) {
|
||||
var caster = input.player();
|
||||
if (input.target() instanceof LivingEntity supplied && eligible(caster, supplied)
|
||||
&& inRangeAndVisible(caster, supplied, range)) return supplied;
|
||||
try {
|
||||
var eye = caster.getEyeLocation();
|
||||
var hit = caster.getWorld().rayTraceEntities(eye, eye.getDirection(), range, entity ->
|
||||
entity instanceof LivingEntity target && eligible(caster, target) && inRangeAndVisible(caster, target, range));
|
||||
if (hit == null || !(hit.getHitEntity() instanceof LivingEntity target)) return null;
|
||||
return eligible(caster, target) && inRangeAndVisible(caster, target, range) ? target : null;
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private LivingEntity next(Player caster, LivingEntity previous, Set<UUID> visited, double range) {
|
||||
List<Entity> nearby;
|
||||
try { nearby = previous.getNearbyEntities(range, range, range); }
|
||||
catch (RuntimeException unavailable) { return null; }
|
||||
var candidates = new ArrayList<LivingEntity>(); var seen = new HashSet<UUID>(); int inspected = 0;
|
||||
for (var entity : nearby) {
|
||||
if (++inspected > 256) break;
|
||||
if (entity instanceof LivingEntity target && seen.add(target.getUniqueId()) && !visited.contains(target.getUniqueId())
|
||||
&& eligible(caster, target) && inRangeAndVisible(previous, target, range)) candidates.add(target);
|
||||
}
|
||||
var origin = previous.getLocation();
|
||||
candidates.sort(Comparator.comparingDouble((LivingEntity target) -> distance(origin, target))
|
||||
.thenComparing(LivingEntity::getUniqueId));
|
||||
return candidates.isEmpty() ? null : candidates.getFirst();
|
||||
}
|
||||
|
||||
private boolean eligible(Player caster, LivingEntity target) {
|
||||
if (target == caster) return false;
|
||||
if (!(target instanceof Player player)) return true;
|
||||
if (sessions.active(player.getUniqueId()).filter(sessions::current).isEmpty()) return false;
|
||||
var sourceParty = parties.partyOf(caster.getUniqueId()).orElse(null);
|
||||
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
|
||||
return sourceParty == null || targetParty == null || !sourceParty.id().equals(targetParty.id());
|
||||
}
|
||||
|
||||
private static boolean inRangeAndVisible(LivingEntity source, LivingEntity target, double range) {
|
||||
try {
|
||||
var from = source.getLocation(); var to = target.getLocation();
|
||||
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static double distance(Location origin, LivingEntity target) {
|
||||
try { var location = target.getLocation(); return origin.getWorld() == location.getWorld()
|
||||
? origin.distanceSquared(location) : Double.POSITIVE_INFINITY; }
|
||||
catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING
|
||||
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
private static void visual(Player caster, LivingEntity target) {
|
||||
try {
|
||||
var location = target.getLocation().clone().add(0, target.getHeight() / 2, 0);
|
||||
caster.getWorld().spawnParticle(Particle.ELECTRIC_SPARK, location, 18, 0.3, 0.5, 0.3, 0.02);
|
||||
caster.getWorld().playSound(location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 0.35f, 1.7f);
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
private static BigDecimal definiteNonnegative(BigDecimal value) { return value.signum() < 0 ? BigDecimal.ZERO : value; }
|
||||
private static double finite(BigDecimal value) {
|
||||
if (value.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0) return Double.MAX_VALUE;
|
||||
double result = value.doubleValue(); return Double.isFinite(result) ? result : Double.MAX_VALUE;
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var p = definition.parameters();
|
||||
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(), p.get("initial-range").doubleValue(),
|
||||
p.get("jump-range").doubleValue(), p.get("additional-targets").intValueExact(), p.get("jump-multiplier"));
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
@Override public void close() { closed = true; damaging = null; }
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
@@ -17,9 +18,13 @@ 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;
|
||||
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current; boolean closed;
|
||||
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
this.execution = execution; this.player = player; this.percent = percent; this.current = current;
|
||||
}
|
||||
boolean valid() {
|
||||
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
|
||||
catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
@@ -33,10 +38,14 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
private boolean closed;
|
||||
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent);
|
||||
return grant(execution, player, percent, () -> true);
|
||||
}
|
||||
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent); Objects.requireNonNull(current);
|
||||
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);
|
||||
var grant = new Grant(execution, player, percent, current);
|
||||
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
|
||||
return grant;
|
||||
}
|
||||
@@ -48,7 +57,7 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
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 (grant.player != player || !grant.valid()) continue;
|
||||
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0) strongest = grant;
|
||||
}
|
||||
double original = event.getDamage();
|
||||
@@ -65,7 +74,7 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
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.valid()) return;
|
||||
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
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.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
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.Entity;
|
||||
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.EntityDamageEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Bounded instantaneous Frost Nova damage and ownership-aware Slowness. */
|
||||
public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double radius,
|
||||
int slownessAmplifier, Duration slownessDuration) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(slownessDuration);
|
||||
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|
||||
|| slownessAmplifier < 0 || slownessAmplifier > 255
|
||||
|| slownessDuration.isNegative() || slownessDuration.isZero()
|
||||
|| slownessDuration.compareTo(Duration.ofDays(1)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Frost Nova settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(5), Duration.ofSeconds(12), 5, 1, Duration.ofSeconds(3));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SlowLease implements AutoCloseable {
|
||||
final LivingEntity target; final PotionEffect previous; final PotionEffect applied;
|
||||
final long started = System.nanoTime(); boolean owned; boolean closed;
|
||||
SlowLease(LivingEntity target, PotionEffect applied) {
|
||||
this.target = target; 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 final class LeaseGroup implements AutoCloseable {
|
||||
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>(); boolean closed;
|
||||
LeaseGroup(AbilityExecution execution) { this.execution = execution; }
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; active.remove(execution, this);
|
||||
for (var lease : List.copyOf(leases)) lease.close(); leases.clear();
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Map<AbilityExecution, LeaseGroup> active = new java.util.IdentityHashMap<>();
|
||||
private Attempt damaging;
|
||||
private boolean closed;
|
||||
|
||||
public FrostNova(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("frost_nova", "Frost Nova", Material.SNOWBALL,
|
||||
List.of("Burst in a " + decimal(BigDecimal.valueOf(current.radius())) + "-block sphere for "
|
||||
+ current.baseDamage() + " + INT modifier damage.",
|
||||
"Applies Slowness " + level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + " seconds."),
|
||||
"Use to affect exposed enemies; caster, party and ordinary players are excluded.",
|
||||
Map.of(Stat.INT, BigInteger.valueOf(16), Stat.DEX, BigInteger.TEN), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()),
|
||||
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
|
||||
"slowness-amplifier", BigDecimal.valueOf(current.slownessAmplifier()),
|
||||
"slowness-duration-seconds", BigDecimal.valueOf(current.slownessDuration().toNanos()).movePointLeft(9)),
|
||||
AbilityDefinition.TargetPolicy.AREA, this);
|
||||
}
|
||||
|
||||
public static BigInteger damage(BigInteger intelligence) { return damage(BigInteger.valueOf(5), 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 Nova damage input");
|
||||
var delta = intelligence.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
var modifier = divided[0]; if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) { return !closed && input.target() == null; }
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var captured = from(execution.definition()); var source = execution.input().player();
|
||||
Location origin = source.getEyeLocation().clone();
|
||||
var group = new LeaseGroup(execution); active.put(execution, group); execution.own(group);
|
||||
BigInteger exact = damage(captured.baseDamage(), execution.character().stats().get(Stat.INT));
|
||||
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
|
||||
int affected = 0;
|
||||
for (var target : candidates(source, origin, captured.radius())) {
|
||||
if (affected == 64 || !execution.stillCurrent()) break;
|
||||
if (!eligible(execution, target, origin, captured.radius())) continue;
|
||||
var sourceDamage = DamageSource.builder(DamageType.FREEZE).withCausingEntity(source)
|
||||
.withDirectEntity(source).withDamageLocation(origin).build();
|
||||
var attempt = new Attempt(execution, target); damaging = attempt;
|
||||
try { target.damage(amount, sourceDamage); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
affected++;
|
||||
if (!attempt.accepted || !execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
|
||||
int ticks = Math.toIntExact(Math.max(1,
|
||||
(captured.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
|
||||
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
|
||||
captured.slownessAmplifier(), false, true, true));
|
||||
if (lease.apply()) group.leases.add(lease);
|
||||
}
|
||||
try { source.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 32,
|
||||
captured.radius() / 2, captured.radius() / 2, captured.radius() / 2, 0.01); }
|
||||
catch (RuntimeException ignored) { }
|
||||
if (group.leases.isEmpty()) execution.finish();
|
||||
else execution.later(captured.slownessDuration(), execution::finish);
|
||||
}
|
||||
|
||||
private List<LivingEntity> candidates(Player source, Location origin, double radius) {
|
||||
List<Entity> nearby;
|
||||
try { nearby = source.getNearbyEntities(radius, radius, radius); }
|
||||
catch (RuntimeException unavailable) { return List.of(); }
|
||||
var unique = new LinkedHashMap<UUID, LivingEntity>(); int inspected = 0;
|
||||
for (var entity : nearby) {
|
||||
if (++inspected > 256) break;
|
||||
if (entity instanceof LivingEntity target && target != source) unique.putIfAbsent(target.getUniqueId(), target);
|
||||
}
|
||||
var result = new ArrayList<>(unique.values());
|
||||
result.sort(Comparator.comparingDouble((LivingEntity target) -> distance(origin, target))
|
||||
.thenComparing(LivingEntity::getUniqueId));
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean eligible(AbilityExecution execution, LivingEntity target, Location origin, double radius) {
|
||||
if (target == execution.input().player() || !inside(origin, target, radius)) return false;
|
||||
try {
|
||||
if (!execution.input().player().hasLineOfSight(target) || !unobstructed(origin, target)) return false;
|
||||
} catch (RuntimeException unavailable) { return false; }
|
||||
if (!(target instanceof Player player)) return true;
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
if (active == null) return false;
|
||||
var sourceParty = parties.partyOf(execution.input().player().getUniqueId()).orElse(null);
|
||||
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
|
||||
return sourceParty == null || targetParty == null || !sourceParty.id().equals(targetParty.id());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE
|
||||
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
private static boolean unobstructed(Location origin, LivingEntity target) {
|
||||
var world = origin.getWorld(); if (world == null) return false;
|
||||
var location = target.getLocation(); if (location.getWorld() != world) return false;
|
||||
var bounds = target.getBoundingBox();
|
||||
var center = bounds == null ? location.toVector().add(new org.bukkit.util.Vector(0, target.getHeight() / 2, 0))
|
||||
: bounds.getCenter();
|
||||
var offset = center.clone().subtract(origin.toVector()); double distance = offset.length();
|
||||
if (!Double.isFinite(distance) || distance == 0) return distance == 0;
|
||||
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance,
|
||||
org.bukkit.FluidCollisionMode.NEVER, true) == null;
|
||||
}
|
||||
|
||||
private static boolean inside(Location origin, LivingEntity target, double radius) {
|
||||
try { var location = target.getLocation(); return origin.getWorld() == location.getWorld()
|
||||
&& origin.distanceSquared(location) <= radius * radius; }
|
||||
catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static double distance(Location origin, LivingEntity target) {
|
||||
try { var location = target.getLocation(); return origin.getWorld() == location.getWorld()
|
||||
? origin.distanceSquared(location) : Double.POSITIVE_INFINITY; }
|
||||
catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var parameters = definition.parameters();
|
||||
return new Settings(parameters.get("base-damage").toBigIntegerExact(), definition.cooldown(),
|
||||
parameters.get("radius").doubleValue(), parameters.get("slowness-amplifier").intValueExact(),
|
||||
Duration.ofNanos(parameters.get("slowness-duration-seconds").movePointRight(9).longValueExact()));
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
private static String seconds(Duration duration) {
|
||||
return decimal(BigDecimal.valueOf(duration.toNanos()).movePointLeft(9));
|
||||
}
|
||||
private static String level(int amplifier) {
|
||||
return switch (amplifier + 1) { case 1 -> "I"; case 2 -> "II"; case 3 -> "III"; case 4 -> "IV"; default -> Integer.toString(amplifier + 1); };
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; damaging = null;
|
||||
for (var group : List.copyOf(active.values())) { group.close(); group.execution.finish(); }
|
||||
active.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
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.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Projectile;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.projectiles.ProjectileSource;
|
||||
|
||||
/** Conserved, non-recursive native combat redirection for Guardian Bond. */
|
||||
public final class GuardianBond implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(Duration cooldown, double range, Duration duration, BigDecimal redirectPercent) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(redirectPercent);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 64
|
||||
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|
||||
|| redirectPercent.signum() < 0 || redirectPercent.compareTo(BigDecimal.valueOf(100)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Guardian Bond settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(Duration.ofSeconds(20), 10, Duration.ofSeconds(6), BigDecimal.valueOf(30));
|
||||
}
|
||||
}
|
||||
|
||||
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
|
||||
public Support {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(supportingCharacter);
|
||||
Objects.requireNonNull(aided); Objects.requireNonNull(amount);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface public interface Damage {
|
||||
BigDecimal apply(Player guardian, Entity attacker, BigDecimal requested);
|
||||
}
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final AbilityExecution execution;
|
||||
final Player guardian;
|
||||
final Player ally;
|
||||
final EquipmentSessions.Active aided;
|
||||
final Settings settings;
|
||||
boolean closed;
|
||||
State(AbilityExecution execution, Player guardian, Player ally,
|
||||
EquipmentSessions.Active aided, Settings settings) {
|
||||
this.execution = execution; this.guardian = guardian; this.ally = ally;
|
||||
this.aided = aided; this.settings = settings;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
byGuardian.remove(guardian.getUniqueId(), this);
|
||||
byAlly.remove(ally.getUniqueId(), this);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NativeTransfer {
|
||||
final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
|
||||
BigDecimal accepted = BigDecimal.ZERO;
|
||||
NativeTransfer(Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
|
||||
this.guardian = guardian; this.attacker = attacker; this.requested = requested; this.available = available;
|
||||
}
|
||||
}
|
||||
|
||||
private static final ThreadLocal<Integer> REDIRECT_DEPTH = ThreadLocal.withInitial(() -> 0);
|
||||
|
||||
private volatile Settings settings;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Damage damage;
|
||||
private final Consumer<Support> supports;
|
||||
private final Map<UUID, State> byGuardian = new java.util.HashMap<>();
|
||||
private final Map<UUID, State> byAlly = new java.util.HashMap<>();
|
||||
private int transferring;
|
||||
private NativeTransfer nativeTransfer;
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
public GuardianBond(Settings settings, EquipmentSessions sessions, PartyRoster parties,
|
||||
Consumer<Support> supports) {
|
||||
this(settings, sessions, parties, null, supports);
|
||||
}
|
||||
|
||||
public GuardianBond(Settings settings, EquipmentSessions sessions, PartyRoster parties,
|
||||
Damage damage, Consumer<Support> supports) {
|
||||
this.settings = Objects.requireNonNull(settings);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
this.damage = damage == null ? this::nativeDamage : damage;
|
||||
this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin);
|
||||
if (closed || sweepTask != null) throw new IllegalStateException("Guardian Bond sweep already installed or closed");
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
|
||||
/** Bound active-link validation prevents an invalid link from becoming valid again later. */
|
||||
public void sweep() {
|
||||
if (closed) return;
|
||||
int checked = 0;
|
||||
for (var state : List.copyOf(byGuardian.values())) {
|
||||
if (!current(state)) breakBond(state);
|
||||
if (++checked == 256) break;
|
||||
}
|
||||
}
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("guardian_bond", "Guardian Bond", Material.IRON_CHESTPLATE,
|
||||
List.of("Redirect " + decimal(current.redirectPercent()) + "% of an aimed party ally's combat damage.",
|
||||
"Range " + decimal(BigDecimal.valueOf(current.range())) + " blocks for " + seconds(current.duration()) + " seconds."),
|
||||
"Use on an aimed active party ally; one guardian and one bond per ally.",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
|
||||
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
|
||||
"redirect-percent", current.redirectPercent()),
|
||||
AbilityDefinition.TargetPolicy.PARTY, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
if (closed || !(input.target() instanceof Player ally) || ally == input.player()) return false;
|
||||
return byGuardian.get(input.player().getUniqueId()) == null && byAlly.get(ally.getUniqueId()) == null
|
||||
&& eligible(input.player(), ally, settings.range()) && aided(ally) != null;
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof Player ally)) return;
|
||||
var guardian = execution.input().player(); var captured = from(execution.definition()); var aided = aided(ally);
|
||||
if (ally == guardian || aided == null || !eligible(guardian, ally, captured.range())
|
||||
|| byGuardian.containsKey(guardian.getUniqueId()) || byAlly.containsKey(ally.getUniqueId())) {
|
||||
execution.finish(); return;
|
||||
}
|
||||
var state = new State(execution, guardian, ally, aided, captured);
|
||||
byGuardian.put(guardian.getUniqueId(), state); byAlly.put(ally.getUniqueId(), state);
|
||||
execution.own(state); execution.later(captured.duration(), execution::finish);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void redirect(EntityDamageByEntityEvent event) {
|
||||
if (closed || redirectedDamage() || transferring != 0 || !(event.getEntity() instanceof Player ally)) return;
|
||||
var state = byAlly.get(ally.getUniqueId());
|
||||
if (state == null) return;
|
||||
if (!current(state)) { breakBond(state); return; }
|
||||
double incoming = event.getDamage();
|
||||
if (!Double.isFinite(incoming) || incoming <= 0) return;
|
||||
BigDecimal requested = BigDecimal.valueOf(incoming)
|
||||
.multiply(state.settings.redirectPercent().movePointLeft(2));
|
||||
if (requested.signum() <= 0) return;
|
||||
Entity attacker = attacker(event);
|
||||
if (attacker == null) return;
|
||||
BigDecimal accepted;
|
||||
transferring++; REDIRECT_DEPTH.set(REDIRECT_DEPTH.get() + 1);
|
||||
try { accepted = damage.apply(state.guardian, attacker, requested); }
|
||||
catch (RuntimeException failure) { return; }
|
||||
finally {
|
||||
transferring--;
|
||||
int depth = REDIRECT_DEPTH.get() - 1;
|
||||
if (depth == 0) REDIRECT_DEPTH.remove(); else REDIRECT_DEPTH.set(depth);
|
||||
}
|
||||
if (accepted == null || accepted.signum() <= 0) return;
|
||||
BigDecimal maximum = requested.min(BigDecimal.valueOf(incoming));
|
||||
if (accepted.compareTo(maximum) > 0) accepted = maximum;
|
||||
double remaining = BigDecimal.valueOf(incoming).subtract(accepted).max(BigDecimal.ZERO).doubleValue();
|
||||
if (!Double.isFinite(remaining)) return;
|
||||
event.setDamage(remaining);
|
||||
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
|
||||
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
|
||||
}
|
||||
|
||||
/** Capture accepted marked damage before synchronous fantasy-defeat restoration can replace native health. */
|
||||
private BigDecimal nativeDamage(Player guardian, Entity attacker, BigDecimal requested) {
|
||||
Objects.requireNonNull(guardian); Objects.requireNonNull(attacker); Objects.requireNonNull(requested);
|
||||
double amount = requested.doubleValue(); double health = effectiveHealth(guardian);
|
||||
if (!Double.isFinite(amount) || amount <= 0 || !Double.isFinite(health) || health <= 0) return BigDecimal.ZERO;
|
||||
var transfer = new NativeTransfer(guardian, attacker, requested, BigDecimal.valueOf(health));
|
||||
nativeTransfer = transfer;
|
||||
try { guardian.damage(amount, attacker); }
|
||||
finally { nativeTransfer = null; }
|
||||
return transfer.accepted;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void acceptedRedirect(EntityDamageByEntityEvent event) {
|
||||
var transfer = nativeTransfer;
|
||||
if (transfer == null || event.getEntity() != transfer.guardian || attacker(event) != transfer.attacker
|
||||
|| event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0) return;
|
||||
transfer.accepted = BigDecimal.valueOf(event.getFinalDamage()).min(transfer.requested).min(transfer.available);
|
||||
}
|
||||
|
||||
/** True only during synchronous dispatch of Guardian Bond's marked redirected damage. */
|
||||
public static boolean redirectedDamage() {
|
||||
int depth = REDIRECT_DEPTH.get();
|
||||
if (depth == 0) REDIRECT_DEPTH.remove();
|
||||
return depth > 0;
|
||||
}
|
||||
|
||||
private EquipmentSessions.Active aided(Player ally) {
|
||||
return sessions.active(ally.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
}
|
||||
|
||||
private boolean current(State state) {
|
||||
if (state.closed || !state.execution.stillCurrent() || state.ally != state.execution.input().target()
|
||||
|| !sessions.current(state.aided)
|
||||
|| !sessions.active(state.ally.getUniqueId()).filter(state.aided::equals).isPresent()
|
||||
|| !eligible(state.guardian, state.ally, state.settings.range())) return false;
|
||||
var guardianParty = parties.partyOf(state.guardian.getUniqueId()).orElse(null);
|
||||
var allyParty = parties.partyOf(state.ally.getUniqueId()).orElse(null);
|
||||
return guardianParty != null && allyParty != null && guardianParty.id().equals(allyParty.id());
|
||||
}
|
||||
|
||||
private void breakBond(State state) {
|
||||
state.close(); state.execution.finish();
|
||||
}
|
||||
|
||||
private static boolean eligible(Player guardian, Player ally, double range) {
|
||||
try {
|
||||
var source = guardian.getLocation(); var target = ally.getLocation();
|
||||
return source.getWorld() == target.getWorld() && source.distanceSquared(target) <= range * range
|
||||
&& guardian.hasLineOfSight(ally);
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
|
||||
private static Entity attacker(EntityDamageByEntityEvent event) {
|
||||
Entity causing = event.getDamageSource().getCausingEntity();
|
||||
if (causing != null) return causing;
|
||||
if (event.getDamager() instanceof Projectile projectile) {
|
||||
ProjectileSource shooter = projectile.getShooter();
|
||||
if (shooter instanceof Entity entity) return entity;
|
||||
}
|
||||
return event.getDamager();
|
||||
}
|
||||
|
||||
private static double effectiveHealth(Player player) {
|
||||
return Math.max(0, player.getHealth()) + Math.max(0, player.getAbsorptionAmount());
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var parameters = definition.parameters();
|
||||
return new Settings(definition.cooldown(), parameters.get("range").doubleValue(),
|
||||
Duration.ofNanos(parameters.get("duration-seconds").movePointRight(9).longValueExact()),
|
||||
parameters.get("redirect-percent"));
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
private static String seconds(Duration duration) {
|
||||
return decimal(BigDecimal.valueOf(duration.toNanos()).movePointLeft(9));
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
if (sweepTask != null) sweepTask.cancel();
|
||||
for (var state : List.copyOf(byGuardian.values())) state.execution.finish();
|
||||
byGuardian.clear(); byAlly.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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.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.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
|
||||
/** Mending Light's aimed party healing with persist-before-publication support evidence. */
|
||||
public final class MendingLight implements AbilityEffect {
|
||||
public record Settings(BigInteger baseHealing, Duration cooldown, double range) {
|
||||
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
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 64) {
|
||||
throw new IllegalArgumentException("Invalid Mending Light settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(8), 12); }
|
||||
}
|
||||
|
||||
public record Support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
|
||||
public Support {
|
||||
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
|
||||
if (amount.signum() <= 0) throw new IllegalArgumentException("Support amount must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
private record Target(Player player, EquipmentSessions.Active active, CharacterRecord record) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<EntityRegainHealthEvent> healingEvents;
|
||||
private final Supplier<BigDecimal> nativeLimit;
|
||||
private final Consumer<Support> supports;
|
||||
private volatile Settings settings;
|
||||
|
||||
public MendingLight(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
|
||||
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
|
||||
Supplier<BigDecimal> nativeLimit, Consumer<Support> supports) {
|
||||
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
|
||||
this.nextTick = Objects.requireNonNull(nextTick); this.healingEvents = Objects.requireNonNull(healingEvents);
|
||||
this.nativeLimit = Objects.requireNonNull(nativeLimit); this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("mending_light", "Mending Light", Material.GHAST_TEAR,
|
||||
List.of("Restore a party ally's missing fantasy health.",
|
||||
"Heals " + current.baseHealing() + " + WIS modifier within " + decimal(current.range()) + " blocks."),
|
||||
"Use while aiming at an injured active party ally; self and full-health targets are refused.",
|
||||
Map.of(Stat.WIS, BigInteger.valueOf(12), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-healing", new BigDecimal(current.baseHealing()),
|
||||
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.PARTY, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
var target = target(input, settings.range());
|
||||
if (target == null) return false;
|
||||
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
|
||||
double health = target.player().getHealth();
|
||||
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 Mending Light 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 configured = from(execution.definition());
|
||||
var target = target(execution.input(), configured.range());
|
||||
if (target == null) { execution.finish(); return; }
|
||||
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) { execution.finish(); return; }
|
||||
double current = target.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(configured.baseHealing(), 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(target.player(), proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event);
|
||||
double accepted = Math.min(proposed, Math.min(missing, event.getAmount()));
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
|
||||
var expected = target.record().revision();
|
||||
BigDecimal persistedHealth = BigDecimal.valueOf(current + accepted);
|
||||
BigDecimal limit = nativeLimit.get();
|
||||
characters.saveHealth(target.record().id(), expected, persistedHealth, limit).whenComplete((saved, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> complete(execution, target, expected, accepted, saved, failure));
|
||||
} catch (RuntimeException rejected) { execution.finish(); }
|
||||
});
|
||||
}
|
||||
|
||||
private void complete(AbilityExecution execution, Target target, BigInteger expected, double accepted,
|
||||
java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty()) {
|
||||
execution.input().player().sendMessage("Mending Light health could not be saved; no healing was applied.");
|
||||
return;
|
||||
}
|
||||
if (!execution.stillCurrent() || !current(execution.input().player(), target, from(execution.definition()).range())) return;
|
||||
var persisted = saved.orElseThrow();
|
||||
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
|
||||
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) return;
|
||||
double current = target.player().getHealth(); double maximum = attribute.getValue();
|
||||
double desired = Math.min(maximum, persisted.health().doubleValue());
|
||||
double restored = Math.min(accepted, desired - current);
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
|
||||
target.player().setHealth(Math.min(maximum, current + restored));
|
||||
var amount = BigDecimal.valueOf(restored).stripTrailingZeros();
|
||||
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
|
||||
execution.input().player().sendMessage("Mending Light restored " + amount.toPlainString() + " health.");
|
||||
target.player().sendMessage("Mending Light restored " + amount.toPlainString() + " health.");
|
||||
} finally { execution.finish(); }
|
||||
}
|
||||
|
||||
private Target target(ControlInput input, double range) {
|
||||
if (!(input.target() instanceof Player player) || player == input.player() || !inRange(input.player(), player, range)
|
||||
|| !sameParty(input.player(), player)) return null;
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return record == null ? null : new Target(player, active, record);
|
||||
}
|
||||
|
||||
private boolean current(Player source, Target target, double range) {
|
||||
return sessions.current(target.active())
|
||||
&& sessions.currentRecord(target.active()).filter(record -> record.id().equals(target.record().id())).isPresent()
|
||||
&& inRange(source, target.player(), range) && sameParty(source, target.player());
|
||||
}
|
||||
|
||||
private boolean sameParty(Player source, Player target) {
|
||||
var party = parties.partyOf(source.getUniqueId()).orElse(null);
|
||||
return party != null && party.members().contains(target.getUniqueId());
|
||||
}
|
||||
|
||||
private static boolean inRange(Player source, Player target, double range) {
|
||||
try {
|
||||
var from = source.getLocation(); var to = target.getLocation();
|
||||
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var parameters = definition.parameters();
|
||||
return new Settings(parameters.get("base-healing").toBigIntegerExact(), definition.cooldown(),
|
||||
parameters.get("range").doubleValue());
|
||||
}
|
||||
|
||||
private static String decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString(); }
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
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.*;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.*;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
|
||||
/** Bounded solid-top Meteor warning followed by one direct, terrain-safe cylinder impact. */
|
||||
public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double targetRange, Duration warning,
|
||||
double radius, double verticalRange) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage);Objects.requireNonNull(cooldown);Objects.requireNonNull(warning);
|
||||
long warningTicks;
|
||||
try { warningTicks=Math.max(1,(warning.toNanos()+49_999_999L)/50_000_000L); }
|
||||
catch(ArithmeticException invalid){throw new IllegalArgumentException("Invalid Meteor warning",invalid);}
|
||||
if(baseDamage.signum()<0||cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0
|
||||
||!Double.isFinite(targetRange)||targetRange<=0||targetRange>64
|
||||
||warning.isZero()||warning.isNegative()||warningTicks>63
|
||||
||!Double.isFinite(radius)||radius<=0||radius>64
|
||||
||!Double.isFinite(verticalRange)||verticalRange<0||verticalRange>64)
|
||||
throw new IllegalArgumentException("Invalid Meteor settings");
|
||||
}
|
||||
public static Settings defaults(){return new Settings(BigInteger.valueOf(14),Duration.ofSeconds(45),24,
|
||||
Duration.ofSeconds(2),5,4);}
|
||||
}
|
||||
private record Ground(Block block,Location origin){ }
|
||||
private static final class Attempt {final AbilityExecution execution;final LivingEntity target;boolean accepted;Attempt(AbilityExecution e,LivingEntity t){execution=e;target=t;}}
|
||||
private final class Warning implements AutoCloseable {
|
||||
final AbilityExecution execution;final Player source;final Ground ground;final Settings captured;final BigInteger intelligence;
|
||||
final int maximumTicks;int elapsed;boolean impacted;boolean closed;
|
||||
Warning(AbilityExecution execution,Ground ground,Settings captured){this.execution=execution;source=execution.input().player();this.ground=ground;this.captured=captured;intelligence=execution.character().stats().get(Stat.INT);maximumTicks=(int)Math.max(1,(captured.warning().toNanos()+49_999_999L)/50_000_000L);}
|
||||
void start(){warnings.put(execution,this);execution.own(this);warningVisual();execution.later(Duration.ofMillis(50),this::step);}
|
||||
void step(){if(closed||impacted)return;if(!execution.stillCurrent()){execution.finish();return;}elapsed++;if(elapsed>=maximumTicks){impact();execution.finish();return;}warningVisual();execution.later(Duration.ofMillis(50),this::step);}
|
||||
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{target.damage(amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
|
||||
boolean groundCurrent(){try{return ground.origin().getWorld()!=null&&ground.block().getWorld()==ground.origin().getWorld()&&ground.block().getType().isSolid();}catch(RuntimeException invalid){return false;}}
|
||||
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);ground.origin().getWorld().spawnParticle(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
|
||||
void impactVisual(){try{var world=ground.origin().getWorld();world.spawnParticle(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);world.spawnParticle(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
|
||||
@Override public void close(){if(closed)return;closed=true;warnings.remove(execution,this);}
|
||||
}
|
||||
|
||||
private volatile Settings settings;private final EquipmentSessions sessions;private final PartyRoster parties;
|
||||
private final Map<AbilityExecution,Warning> warnings=new IdentityHashMap<>();private Attempt damaging;private boolean closed;
|
||||
public Meteor(Settings settings,EquipmentSessions sessions,PartyRoster parties){this.settings=Objects.requireNonNull(settings);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);}
|
||||
public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
|
||||
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("meteor","Meteor",Material.FIRE_CHARGE,
|
||||
List.of("Mark visible ground within "+decimal(current.targetRange())+" blocks for a "+seconds(current.warning())+"-second warning.","Impact exposed enemies in a "+decimal(current.radius())+"-block area for "+current.baseDamage()+" + INT modifier damage."),
|
||||
"Use while looking at a solid top surface; the warning is visible and the impact never changes terrain.",Map.of(Stat.INT,BigInteger.valueOf(26),Stat.WIS,BigInteger.valueOf(20),Stat.CON,BigInteger.valueOf(12)),BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("base-damage",new BigDecimal(current.baseDamage()),"target-range",BigDecimal.valueOf(current.targetRange()).stripTrailingZeros(),"warning-seconds",nanos(current.warning()),"radius",BigDecimal.valueOf(current.radius()).stripTrailingZeros(),"vertical-range",BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros()),AbilityDefinition.TargetPolicy.AREA,this);}
|
||||
public static BigInteger damage(BigInteger intelligence){return damage(BigInteger.valueOf(14),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 Meteor damage input");var delta=intelligence.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&÷d[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
|
||||
@Override public boolean accepts(ControlInput input){return !closed&&input.target()==null&&ground(input.player(),settings.targetRange())!=null;}
|
||||
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent())return;var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());if(target==null){execution.finish();return;}new Warning(execution,target,captured).start();}
|
||||
private static Ground ground(Player player,double range){try{var eye=player.getEyeLocation();var hit=player.getWorld().rayTraceBlocks(eye,eye.getDirection(),range,FluidCollisionMode.NEVER,true);if(hit==null||hit.getHitBlock()==null||hit.getHitBlockFace()!=BlockFace.UP||!hit.getHitBlock().getType().isSolid())return null;var block=hit.getHitBlock();return new Ground(block,block.getLocation().add(.5,1,.5));}catch(RuntimeException invalid){return null;}}
|
||||
private List<LivingEntity> candidates(Location origin,double radius,double vertical){List<Entity> nearby;try{nearby=new ArrayList<>(origin.getWorld().getNearbyEntities(origin,radius,vertical,radius));}catch(RuntimeException unavailable){return List.of();}var unique=new LinkedHashMap<UUID,LivingEntity>();int inspected=0;for(var entity:nearby){if(++inspected>256)break;if(entity instanceof LivingEntity living)unique.putIfAbsent(living.getUniqueId(),living);}var result=new ArrayList<>(unique.values());result.sort(Comparator.comparingDouble((LivingEntity target)->distance(origin,target)).thenComparing(LivingEntity::getUniqueId));return result;}
|
||||
private boolean eligible(AbilityExecution execution,LivingEntity target,Location origin,double radius,double vertical){var source=execution.input().player();if(target==source||!inside(origin,target,radius,vertical)||!unobstructed(origin,target))return false;if(!(target instanceof Player player))return true;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return false;var sourceParty=parties.partyOf(source.getUniqueId()).orElse(null);var targetParty=parties.partyOf(player.getUniqueId()).orElse(null);return sourceParty==null||targetParty==null||!sourceParty.id().equals(targetParty.id());}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void acceptedDamage(EntityDamageEvent event){var attempt=damaging;if(attempt==null||attempt.accepted||event.isCancelled()||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;attempt.accepted=true;attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));}
|
||||
private static boolean inside(Location origin,LivingEntity target,double radius,double vertical){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return false;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz<=radius*radius&&Math.abs(at.getY()-origin.getY())<=vertical;}catch(RuntimeException invalid){return false;}}
|
||||
private static boolean unobstructed(Location origin,LivingEntity target){try{var world=origin.getWorld();var at=target.getLocation();if(world==null||at.getWorld()!=world)return false;var bounds=target.getBoundingBox();var center=bounds==null?at.toVector().add(new org.bukkit.util.Vector(0,target.getHeight()/2,0)):bounds.getCenter();var offset=center.subtract(origin.toVector());double distance=offset.length();if(!Double.isFinite(distance)||distance==0)return distance==0;return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;}catch(RuntimeException invalid){return false;}}
|
||||
private static double distance(Location origin,LivingEntity target){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;}catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}}
|
||||
private static double finite(BigInteger value){return value.bitLength()>1023?Double.MAX_VALUE:value.doubleValue();}
|
||||
private static Settings from(AbilityDefinition definition){var p=definition.parameters();return new Settings(p.get("base-damage").toBigIntegerExact(),definition.cooldown(),p.get("target-range").doubleValue(),Duration.ofNanos(p.get("warning-seconds").movePointRight(9).longValueExact()),p.get("radius").doubleValue(),p.get("vertical-range").doubleValue());}
|
||||
private static BigDecimal nanos(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9);}private static String seconds(Duration value){return decimal(nanos(value));}private static String decimal(double value){return decimal(BigDecimal.valueOf(value));}private static String decimal(BigDecimal value){return value.stripTrailingZeros().toPlainString();}
|
||||
@Override public void close(){if(closed)return;closed=true;damaging=null;for(var warning:List.copyOf(warnings.values())){warning.close();warning.execution.finish();}warnings.clear();}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
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 java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Projectile;
|
||||
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.projectiles.ProjectileSource;
|
||||
|
||||
/** Strongest deterministic Fantasy Stick outgoing combat bonus stage. */
|
||||
public final class OutgoingDamageBonus implements Listener, AutoCloseable {
|
||||
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) { }
|
||||
private final class Grant implements AutoCloseable {
|
||||
final long sequence; final AbilityExecution execution; final Player recipient;
|
||||
final BigDecimal percent; final BooleanSupplier current; boolean closed;
|
||||
Grant(AbilityExecution execution, Player recipient, BigDecimal percent, BooleanSupplier current) {
|
||||
sequence = ++sequences; this.execution = execution; this.recipient = recipient;
|
||||
this.percent = percent; this.current = current;
|
||||
}
|
||||
boolean valid() {
|
||||
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
|
||||
catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
var owned = grants.get(recipient.getUniqueId());
|
||||
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(recipient.getUniqueId()); }
|
||||
}
|
||||
}
|
||||
private record Used(Grant grant, Entity target, BigDecimal amount) { }
|
||||
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Consumer<Support> supports;
|
||||
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
|
||||
private final Map<EntityDamageEvent, Used> pending = new IdentityHashMap<>();
|
||||
private long sequences;
|
||||
private boolean closed;
|
||||
|
||||
public OutgoingDamageBonus(EquipmentSessions sessions, PartyRoster parties, Consumer<Support> supports) {
|
||||
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
|
||||
this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
public AutoCloseable grant(AbilityExecution execution, Player recipient, BigDecimal percent, BooleanSupplier current) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(recipient);
|
||||
Objects.requireNonNull(percent); Objects.requireNonNull(current);
|
||||
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(1000)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid outgoing damage bonus");
|
||||
}
|
||||
var grant = new Grant(execution, recipient, percent, current);
|
||||
grants.computeIfAbsent(recipient.getUniqueId(), ignored -> new ArrayList<>()).add(grant); return grant;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
|
||||
public void increase(EntityDamageByEntityEvent event) {
|
||||
if (closed || GuardianBond.redirectedDamage()) return;
|
||||
Entity attacker = attacker(event); if (!(attacker instanceof Player player)) return;
|
||||
var owned = grants.get(player.getUniqueId()); if (owned == null) return;
|
||||
Grant strongest = null;
|
||||
for (var grant : List.copyOf(owned)) {
|
||||
if (grant.recipient != player || !grant.valid()) continue;
|
||||
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0
|
||||
|| grant.percent.compareTo(strongest.percent) == 0 && grant.sequence < strongest.sequence) strongest = grant;
|
||||
}
|
||||
double original = event.getDamage();
|
||||
if (strongest == null || !Double.isFinite(original) || original <= 0) return;
|
||||
BigDecimal added = BigDecimal.valueOf(original).multiply(strongest.percent.movePointLeft(2));
|
||||
double increased = BigDecimal.valueOf(original).add(added).doubleValue();
|
||||
if (!Double.isFinite(increased) || increased < 0 || added.signum() <= 0) return;
|
||||
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void accepted(EntityDamageEvent event) {
|
||||
var used = pending.remove(event);
|
||||
if (used == null || event.isCancelled() || event.getFinalDamage() <= 0 || !used.grant.valid()) return;
|
||||
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, used.amount);
|
||||
var aided = sessions.active(used.grant.recipient.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
if (aided == null || aided.character().equals(used.grant.execution.input().session().character())) return;
|
||||
var sourceParty = parties.partyOf(used.grant.execution.input().player().getUniqueId()).orElse(null);
|
||||
var aidedParty = parties.partyOf(used.grant.recipient.getUniqueId()).orElse(null);
|
||||
if (sourceParty == null || aidedParty == null || !sourceParty.id().equals(aidedParty.id())) return;
|
||||
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, used.amount));
|
||||
}
|
||||
|
||||
private static Entity attacker(EntityDamageByEntityEvent event) {
|
||||
Entity causing = event.getDamageSource().getCausingEntity(); if (causing != null) return causing;
|
||||
if (event.getDamager() instanceof Projectile projectile) {
|
||||
ProjectileSource shooter = projectile.getShooter(); if (shooter instanceof Entity entity) return entity;
|
||||
}
|
||||
return event.getDamager();
|
||||
}
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ public final class OutgoingDamageMitigation implements Listener, AutoCloseable {
|
||||
|
||||
@EventHandler(priority=EventPriority.NORMAL,ignoreCancelled=true)
|
||||
public void reduce(EntityDamageByEntityEvent event){
|
||||
if(closed)return;Entity attacker=attacker(event);if(attacker==null)return;var owned=grants.get(attacker.getUniqueId());if(owned==null)return;
|
||||
if(closed||GuardianBond.redirectedDamage())return;Entity attacker=attacker(event);if(attacker==null)return;var owned=grants.get(attacker.getUniqueId());if(owned==null)return;
|
||||
Grant strongest=null;for(var grant:List.copyOf(owned)){if(grant.closed||grant.target!=attacker||!grant.execution.stillCurrent())continue;
|
||||
if(strongest==null||grant.percent.compareTo(strongest.percent)>0
|
||||
||grant.percent.compareTo(strongest.percent)==0&&grant.sequence>strongest.sequence)strongest=grant;}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
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.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Purify's self-or-aimed-party native harmful-effect removal. */
|
||||
public final class Purify implements AbilityEffect {
|
||||
public record Settings(Duration cooldown, double range) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 64) {
|
||||
throw new IllegalArgumentException("Invalid Purify settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(Duration.ofSeconds(15), 12); }
|
||||
}
|
||||
|
||||
public record Support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
|
||||
public Support {
|
||||
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
|
||||
if (amount.signum() <= 0) throw new IllegalArgumentException("Support amount must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
private record Target(Player player, EquipmentSessions.Active active) { }
|
||||
private static final List<PotionEffectType> HARMFUL = List.of(
|
||||
PotionEffectType.POISON, PotionEffectType.WITHER, PotionEffectType.SLOWNESS, PotionEffectType.WEAKNESS);
|
||||
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Consumer<Support> supports;
|
||||
private volatile Settings settings;
|
||||
|
||||
public Purify(Settings settings, EquipmentSessions sessions, PartyRoster parties, Consumer<Support> supports) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties); this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("purify", "Purify", Material.HONEY_BOTTLE,
|
||||
List.of("Remove Poison, Wither, Slowness and Weakness.",
|
||||
"Aimed party-ally range: " + decimal(current.range()) + " blocks; beneficial effects remain."),
|
||||
"Sneak+Use cleanses self; normal Use requires an aimed active party ally.",
|
||||
Map.of(Stat.WIS, BigInteger.valueOf(14), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.PARTY, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
var target = target(input, settings.range());
|
||||
return target != null && HARMFUL.stream().anyMatch(type -> target.player().getPotionEffect(type) != null);
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (!execution.stillCurrent()) return;
|
||||
var configured = from(execution.definition()); var target = target(execution.input(), configured.range());
|
||||
if (target == null) return;
|
||||
int removed = 0;
|
||||
for (var type : HARMFUL) {
|
||||
if (!execution.stillCurrent() || !current(execution.input(), target, configured.range())) break;
|
||||
if (target.player().getPotionEffect(type) == null) continue;
|
||||
target.player().removePotionEffect(type);
|
||||
if (target.player().getPotionEffect(type) == null) removed++;
|
||||
}
|
||||
if (removed == 0) return;
|
||||
execution.input().player().sendMessage("Purify removed " + removed + " harmful effect" + (removed == 1 ? "." : "s."));
|
||||
if (target.player() != execution.input().player()
|
||||
&& execution.stillCurrent() && current(execution.input(), target, configured.range())) {
|
||||
var amount = BigDecimal.valueOf(removed);
|
||||
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
|
||||
target.player().sendMessage("Purify removed " + removed + " harmful effect" + (removed == 1 ? "." : "s."));
|
||||
}
|
||||
}
|
||||
|
||||
private Target target(ControlInput input, double range) {
|
||||
if (!(input.target() instanceof Player player)) return null;
|
||||
if (input.player().isSneaking()) {
|
||||
if (player != input.player()) return null;
|
||||
} else if (player == input.player() || !inRange(input.player(), player, range) || !sameParty(input.player(), player)) {
|
||||
return null;
|
||||
}
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
return active == null ? null : new Target(player, active);
|
||||
}
|
||||
|
||||
private boolean current(ControlInput input, Target target, double range) {
|
||||
if (!sessions.current(target.active())) return false;
|
||||
if (target.player() == input.player()) return input.player().isSneaking();
|
||||
return !input.player().isSneaking() && inRange(input.player(), target.player(), range)
|
||||
&& sameParty(input.player(), target.player());
|
||||
}
|
||||
|
||||
private boolean sameParty(Player source, Player target) {
|
||||
var party = parties.partyOf(source.getUniqueId()).orElse(null);
|
||||
return party != null && party.members().contains(target.getUniqueId());
|
||||
}
|
||||
|
||||
private static boolean inRange(Player source, Player target, double range) {
|
||||
try {
|
||||
var from = source.getLocation(); var to = target.getLocation();
|
||||
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
return new Settings(definition.cooldown(), definition.parameters().get("range").doubleValue());
|
||||
}
|
||||
|
||||
private static String decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString(); }
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
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.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Dynamic owned party sphere for Rallying Cry movement and outgoing damage benefits. */
|
||||
public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
public record Settings(Duration cooldown, double radius, Duration duration,
|
||||
int speedAmplifier, BigDecimal damageBonusPercent) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(damageBonusPercent);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|
||||
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|
||||
|| speedAmplifier < 0 || speedAmplifier > 255
|
||||
|| damageBonusPercent.signum() < 0 || damageBonusPercent.compareTo(BigDecimal.valueOf(100)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Rallying Cry settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(Duration.ofSeconds(25), 8, Duration.ofSeconds(6), 0, BigDecimal.valueOf(20));
|
||||
}
|
||||
}
|
||||
|
||||
private final class Area implements AutoCloseable {
|
||||
final long sequence; final AbilityExecution execution; final Player source; final Settings settings;
|
||||
final Map<UUID, Benefit> recipients = new java.util.HashMap<>(); boolean closed;
|
||||
Area(AbilityExecution execution, Settings settings) {
|
||||
sequence = ++sequences; this.execution = execution; source = execution.input().player(); this.settings = settings;
|
||||
}
|
||||
void refresh() {
|
||||
if (closed) return;
|
||||
if (!sourceCurrent()) { breakArea(this); return; }
|
||||
var eligible = candidates();
|
||||
for (var entry : List.copyOf(recipients.entrySet())) {
|
||||
if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
|
||||
}
|
||||
for (var entry : eligible.entrySet()) {
|
||||
if (!recipients.containsKey(entry.getKey())) {
|
||||
var benefit = new Benefit(this, entry.getValue()); recipients.put(entry.getKey(), benefit);
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean sourceCurrent() {
|
||||
return execution.stillCurrent() && sessions.active(source.getUniqueId())
|
||||
.filter(active -> active.equals(execution.input().session()) && sessions.current(active)).isPresent();
|
||||
}
|
||||
boolean eligible(Player player) {
|
||||
if (!sourceCurrent()) return false;
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
if (active == null || !inside(source, player, settings.radius())) return false;
|
||||
if (player == source) return active.equals(execution.input().session());
|
||||
var sourceParty = parties.partyOf(source.getUniqueId()).orElse(null);
|
||||
var recipientParty = parties.partyOf(player.getUniqueId()).orElse(null);
|
||||
return sourceParty != null && recipientParty != null && sourceParty.id().equals(recipientParty.id());
|
||||
}
|
||||
Map<UUID, Player> candidates() {
|
||||
var result = new LinkedHashMap<UUID, Player>();
|
||||
if (eligible(source)) result.put(source.getUniqueId(), source);
|
||||
List<Entity> nearby;
|
||||
try { nearby = source.getNearbyEntities(settings.radius(), settings.radius(), settings.radius()); }
|
||||
catch (RuntimeException unavailable) { nearby = List.of(); }
|
||||
int inspected = 0;
|
||||
for (var entity : nearby) {
|
||||
if (++inspected > 256 || result.size() == 64) break;
|
||||
if (entity instanceof Player player && eligible(player)) result.putIfAbsent(player.getUniqueId(), player);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; areas.remove(sequence, this);
|
||||
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private final class Benefit implements AutoCloseable {
|
||||
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus; boolean closed;
|
||||
Benefit(Area area, Player player) {
|
||||
this.area = area; this.player = player;
|
||||
speed = speed(player, area.settings.speedAmplifier());
|
||||
bonus = bonuses.grant(area.execution, player, area.settings.damageBonusPercent(), () -> !closed && area.eligible(player));
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
|
||||
closeResource(bonus); closeResource(speed);
|
||||
}
|
||||
}
|
||||
|
||||
private final class SpeedGrant implements AutoCloseable {
|
||||
final SpeedState state; final long sequence; final int amplifier; boolean closed;
|
||||
SpeedGrant(SpeedState state, int amplifier) { this.state = state; sequence = ++speedSequences; this.amplifier = amplifier; }
|
||||
@Override public void close() { if (closed) return; closed = true; state.grants.remove(this); state.refresh(); }
|
||||
}
|
||||
private final class SpeedState {
|
||||
final Player player; final List<SpeedGrant> grants = new ArrayList<>(); PotionEffect owned;
|
||||
SpeedState(Player player) { this.player = player; }
|
||||
void refresh() {
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(PotionEffectType.SPEED); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (owned != null && !same(current, owned)) owned = null;
|
||||
SpeedGrant strongest = null;
|
||||
for (var grant : List.copyOf(grants)) if (!grant.closed && (strongest == null
|
||||
|| grant.amplifier > strongest.amplifier
|
||||
|| grant.amplifier == strongest.amplifier && grant.sequence < strongest.sequence)) strongest = grant;
|
||||
if (strongest == null) {
|
||||
if (owned != null && same(current, owned) && !removeOwned()) return;
|
||||
owned = null; speeds.remove(player.getUniqueId(), this); return;
|
||||
}
|
||||
if (owned != null && same(current, owned) && owned.getAmplifier() != strongest.amplifier) {
|
||||
if (!removeOwned()) return;
|
||||
current = null;
|
||||
}
|
||||
if (current != null && current.getAmplifier() >= strongest.amplifier) return;
|
||||
var effect = new PotionEffect(PotionEffectType.SPEED, PotionEffect.INFINITE_DURATION,
|
||||
strongest.amplifier, false, false, true);
|
||||
try { if (player.addPotionEffect(effect, true)) owned = effect; }
|
||||
catch (RuntimeException ignored) { owned = null; }
|
||||
}
|
||||
boolean removeOwned() {
|
||||
try {
|
||||
player.removePotionEffect(PotionEffectType.SPEED);
|
||||
if (same(player.getPotionEffect(PotionEffectType.SPEED), owned)) return false;
|
||||
} catch (RuntimeException failure) { return false; }
|
||||
owned = null; return true;
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final OutgoingDamageBonus bonuses;
|
||||
private final Map<Long, Area> areas = new java.util.HashMap<>();
|
||||
private final Map<UUID, SpeedState> speeds = new java.util.HashMap<>();
|
||||
private long sequences; private long speedSequences;
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
public RallyingCry(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties); this.bonuses = Objects.requireNonNull(bonuses);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin);
|
||||
if (closed || sweepTask != null) throw new IllegalStateException("Rallying Cry sweep already installed or closed");
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
public void sweep() {
|
||||
if (closed) return; int checked = 0;
|
||||
for (var area : List.copyOf(areas.values())) { area.refresh(); if (++checked == 64) break; }
|
||||
for (var state : List.copyOf(speeds.values())) state.refresh();
|
||||
}
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("rallying_cry", "Rallying Cry", Material.GOAT_HORN,
|
||||
List.of("Grant nearby active party members Speed " + (current.speedAmplifier() + 1)
|
||||
+ " and " + decimal(current.damageBonusPercent()) + "% combat damage.",
|
||||
"A " + decimal(BigDecimal.valueOf(current.radius())) + "-block sphere lasting " + seconds(current.duration()) + " seconds."),
|
||||
"Use to rally yourself and nearby active party members; copies do not stack.",
|
||||
Map.of(Stat.CHR, BigInteger.valueOf(16), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
|
||||
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
|
||||
"speed-amplifier", BigDecimal.valueOf(current.speedAmplifier()),
|
||||
"damage-bonus-percent", current.damageBonusPercent()), AbilityDefinition.TargetPolicy.AREA, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) { return !closed && input.target() == null; }
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var area = new Area(execution, from(execution.definition())); areas.put(area.sequence, area);
|
||||
execution.own(area); area.refresh(); execution.later(area.settings.duration(), execution::finish);
|
||||
}
|
||||
|
||||
private AutoCloseable speed(Player player, int amplifier) {
|
||||
var state = speeds.computeIfAbsent(player.getUniqueId(), ignored -> new SpeedState(player));
|
||||
var grant = new SpeedGrant(state, amplifier); state.grants.add(grant); state.refresh(); return grant;
|
||||
}
|
||||
private void breakArea(Area area) { area.close(); area.execution.finish(); }
|
||||
private static boolean inside(Player source, Player target, double radius) {
|
||||
try { var a = source.getLocation(); var b = target.getLocation();
|
||||
return a.getWorld() == b.getWorld() && a.distanceSquared(b) <= radius * radius;
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static boolean same(PotionEffect first, PotionEffect second) {
|
||||
return first != null && second != null && first.getType().equals(second.getType())
|
||||
&& first.getAmplifier() == second.getAmplifier() && first.isInfinite() && second.isInfinite();
|
||||
}
|
||||
private static void closeResource(AutoCloseable resource) {
|
||||
try { resource.close(); } catch (Exception impossible) { throw new IllegalStateException(impossible); }
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var parameters = definition.parameters();
|
||||
return new Settings(definition.cooldown(), parameters.get("radius").doubleValue(),
|
||||
Duration.ofNanos(parameters.get("duration-seconds").movePointRight(9).longValueExact()),
|
||||
parameters.get("speed-amplifier").intValueExact(), parameters.get("damage-bonus-percent"));
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
private static String seconds(Duration duration) {
|
||||
return decimal(BigDecimal.valueOf(duration.toNanos()).movePointLeft(9));
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; if (sweepTask != null) sweepTask.cancel();
|
||||
for (var area : List.copyOf(areas.values())) area.execution.finish(); areas.clear();
|
||||
for (var state : List.copyOf(speeds.values())) { state.grants.clear(); state.refresh(); }
|
||||
speeds.clear();
|
||||
}
|
||||
}
|
||||
@@ -269,7 +269,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
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(),
|
||||
dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), dead.passives(), dead.cooldowns(), dead.restrictedKills(),
|
||||
false, dead.deathId(), dead.diedAt());
|
||||
ItemStack sheet = sheets.create(provisional);
|
||||
ItemStack source = view.target.getInventory().getItem(reservation.sourceSlot());
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
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.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.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
|
||||
/** Fixed bounded Sanctuary cylinder with delayed healing pulses and dynamic strongest mitigation. */
|
||||
public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
public record Settings(Duration cooldown, double radius, double verticalRange, Duration duration,
|
||||
Duration pulseInterval, BigDecimal healingPerPulse, BigDecimal damageReductionPercent) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(pulseInterval);
|
||||
Objects.requireNonNull(healingPerPulse); Objects.requireNonNull(damageReductionPercent);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|
||||
|| !Double.isFinite(verticalRange) || verticalRange < 0 || verticalRange > 64
|
||||
|| duration.isZero() || duration.isNegative() || duration.compareTo(Duration.ofDays(1)) > 0
|
||||
|| pulseInterval.isZero() || pulseInterval.isNegative() || pulseInterval.compareTo(duration) > 0
|
||||
|| duration.toNanos() / pulseInterval.toNanos() > 64
|
||||
|| healingPerPulse.signum() < 0 || healingPerPulse.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|
||||
|| damageReductionPercent.signum() < 0 || damageReductionPercent.compareTo(BigDecimal.valueOf(100)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Sanctuary settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(Duration.ofSeconds(45), 6, 3, Duration.ofSeconds(6), Duration.ofSeconds(1),
|
||||
BigDecimal.valueOf(2), BigDecimal.valueOf(20));
|
||||
}
|
||||
}
|
||||
private record Target(Player player, EquipmentSessions.Active active, CharacterRecord record) { }
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final AbilityExecution execution; final Player source;
|
||||
final Location origin; final Settings captured; final Map<UUID, AutoCloseable> mitigation = new LinkedHashMap<>();
|
||||
Duration elapsed = Duration.ZERO; Duration nextPulse; final Duration scanPeriod;
|
||||
int pending; boolean expired; boolean closed;
|
||||
State(AbilityExecution execution, Settings captured) {
|
||||
this.execution = execution; this.source = execution.input().player(); this.origin = source.getLocation().clone();
|
||||
this.captured = captured; this.nextPulse = captured.pulseInterval();
|
||||
long period = Math.max(50_000_000L, (captured.duration().toNanos() + 59) / 60);
|
||||
this.scanPeriod = Duration.ofNanos(period);
|
||||
}
|
||||
void start() { states.add(this); execution.own(this); reconcile(); visual(); schedule(); }
|
||||
void schedule() {
|
||||
if (closed || expired) return;
|
||||
Duration remaining = captured.duration().minus(elapsed);
|
||||
execution.later(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod, this::step);
|
||||
}
|
||||
void step() {
|
||||
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
|
||||
Duration remaining = captured.duration().minus(elapsed);
|
||||
elapsed = elapsed.plus(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod);
|
||||
reconcile(); visual();
|
||||
while (nextPulse.compareTo(elapsed) <= 0 && nextPulse.compareTo(captured.duration()) <= 0) {
|
||||
pulse(); nextPulse = nextPulse.plus(captured.pulseInterval());
|
||||
}
|
||||
if (elapsed.compareTo(captured.duration()) >= 0) {
|
||||
expired = true; closeMitigation(); if (pending == 0) execution.finish();
|
||||
} else schedule();
|
||||
}
|
||||
void reconcile() {
|
||||
var eligible = recipients();
|
||||
for (var entry : List.copyOf(mitigation.entrySet())) {
|
||||
if (eligible.stream().noneMatch(player -> player.getUniqueId().equals(entry.getKey()))) {
|
||||
try { entry.getValue().close(); } catch (Exception ignored) { } mitigation.remove(entry.getKey());
|
||||
}
|
||||
}
|
||||
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored ->
|
||||
combatMitigation.grant(execution, player, captured.damageReductionPercent(), () -> recipientCurrent(player)));
|
||||
}
|
||||
void pulse() {
|
||||
for (var player : recipients()) if (owner(player) == this) heal(player);
|
||||
}
|
||||
void heal(Player player) {
|
||||
var target = target(player); if (target == null) return;
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH); if (attribute == null) return;
|
||||
double current = player.getHealth(), maximum = attribute.getValue(), missing = maximum - current;
|
||||
double proposed = Math.min(missing, captured.healingPerPulse().doubleValue());
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(proposed) || proposed <= 0) return;
|
||||
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event); double accepted = Math.min(proposed, Math.min(missing, event.getAmount()));
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
|
||||
pending++; var expected = target.record().revision();
|
||||
characters.saveHealth(target.record().id(), expected, BigDecimal.valueOf(current + accepted), nativeLimit.get())
|
||||
.whenComplete((saved, failure) -> {
|
||||
try { nextTick.accept(() -> completeHeal(this, target, expected, accepted, saved, failure)); }
|
||||
catch (RuntimeException rejected) { completePending(this); }
|
||||
});
|
||||
}
|
||||
List<Player> recipients() {
|
||||
List<Entity> nearby;
|
||||
try { nearby = new ArrayList<>(source.getWorld().getNearbyEntities(origin,
|
||||
captured.radius(), captured.verticalRange(), captured.radius())); }
|
||||
catch (RuntimeException unavailable) { nearby = new ArrayList<>(); }
|
||||
nearby.addFirst(source); var unique = new LinkedHashMap<UUID, Player>(); int inspected = 0;
|
||||
for (var entity : nearby) {
|
||||
if (++inspected > 256) break;
|
||||
if (entity instanceof Player player && recipientCurrent(player)) unique.putIfAbsent(player.getUniqueId(), player);
|
||||
}
|
||||
var result = new ArrayList<>(unique.values());
|
||||
result.sort(Comparator.comparingDouble((Player player) -> distance(origin, player)).thenComparing(Player::getUniqueId));
|
||||
return result.size() <= 64 ? result : new ArrayList<>(result.subList(0, 64));
|
||||
}
|
||||
boolean recipientCurrent(Player player) {
|
||||
if (closed || !execution.stillCurrent() || !inside(origin, player, captured.radius(), captured.verticalRange())
|
||||
|| !unobstructed(origin, player)) return false;
|
||||
if (player == source) return sessions.active(player.getUniqueId()).filter(sessions::current)
|
||||
.map(active -> active.equals(execution.input().session())).orElse(false);
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
return active != null && sameParty(source, player);
|
||||
}
|
||||
Target target(Player player) {
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return record == null ? null : new Target(player, active, record);
|
||||
}
|
||||
void visual() {
|
||||
try { source.getWorld().spawnParticle(Particle.END_ROD, origin, 12,
|
||||
captured.radius() / 2, 0.1, captured.radius() / 2, 0); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
void closeMitigation() {
|
||||
for (var grant : List.copyOf(mitigation.values())) try { grant.close(); } catch (Exception ignored) { }
|
||||
mitigation.clear();
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; expired = true; states.remove(this); closeMitigation();
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final CharacterStorage characters; private final EquipmentSessions sessions; private final PartyRoster parties;
|
||||
private final CombatMitigation combatMitigation; private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<EntityRegainHealthEvent> healingEvents; private final Supplier<BigDecimal> nativeLimit;
|
||||
private final Consumer<MendingLight.Support> supports; private final List<State> states = new ArrayList<>();
|
||||
private boolean closed;
|
||||
|
||||
public Sanctuary(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
|
||||
CombatMitigation combatMitigation, Consumer<Runnable> nextTick,
|
||||
Consumer<EntityRegainHealthEvent> healingEvents, Supplier<BigDecimal> nativeLimit,
|
||||
Consumer<MendingLight.Support> supports) {
|
||||
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
|
||||
this.combatMitigation = Objects.requireNonNull(combatMitigation); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
|
||||
this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("sanctuary", "Sanctuary", Material.SEA_LANTERN,
|
||||
List.of("Create a fixed " + decimal(current.radius()) + "-block sanctuary for " + seconds(current.duration()) + " seconds.",
|
||||
"Party members inside heal " + decimal(current.healingPerPulse()) + " each second and take "
|
||||
+ decimal(current.damageReductionPercent()) + "% less combat damage."),
|
||||
"Use to hold exposed ground; healing starts after one second and the area does not follow you.",
|
||||
Map.of(Stat.WIS, BigInteger.valueOf(24), Stat.CHR, BigInteger.valueOf(20), Stat.CON, BigInteger.TEN), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
|
||||
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
|
||||
"duration-seconds", nanos(current.duration()), "pulse-interval-seconds", nanos(current.pulseInterval()),
|
||||
"healing-per-pulse", current.healingPerPulse(), "damage-reduction-percent", current.damageReductionPercent()),
|
||||
AbilityDefinition.TargetPolicy.AREA, this);
|
||||
}
|
||||
@Override public boolean accepts(ControlInput input) { return !closed && input.target() == null; }
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return; new State(execution, from(execution.definition())).start();
|
||||
}
|
||||
|
||||
private void completeHeal(State state, Target target, BigInteger expected, double accepted,
|
||||
java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty() || !state.execution.stillCurrent()
|
||||
|| !state.recipientCurrent(target.player())) return;
|
||||
var persisted = saved.orElseThrow();
|
||||
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
|
||||
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH); if (attribute == null) return;
|
||||
double current = target.player().getHealth(), maximum = attribute.getValue();
|
||||
double desired = Math.min(maximum, persisted.health().doubleValue()); double restored = Math.min(accepted, desired - current);
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
|
||||
target.player().setHealth(Math.min(maximum, current + restored));
|
||||
if (target.player() != state.source) supports.accept(new MendingLight.Support(
|
||||
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(restored).stripTrailingZeros()));
|
||||
} finally { completePending(state); }
|
||||
}
|
||||
private static void completePending(State state) {
|
||||
if (state.pending > 0) state.pending--; if (state.expired && state.pending == 0) state.execution.finish();
|
||||
}
|
||||
private State owner(Player player) {
|
||||
for (var state : List.copyOf(states)) if (!state.expired
|
||||
&& state.mitigation.containsKey(player.getUniqueId())) return state;
|
||||
return null;
|
||||
}
|
||||
private boolean sameParty(Player source, Player target) {
|
||||
var party = parties.partyOf(source.getUniqueId()).orElse(null);
|
||||
return party != null && party.members().contains(target.getUniqueId());
|
||||
}
|
||||
private static boolean inside(Location origin, Player player, double radius, double vertical) {
|
||||
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return false;
|
||||
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ();
|
||||
return dx * dx + dz * dz <= radius * radius && Math.abs(at.getY() - origin.getY()) <= vertical;
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static boolean unobstructed(Location origin, Player player) {
|
||||
try { var world = origin.getWorld(); if (world == null) return false; var box = player.getBoundingBox();
|
||||
var center = box.getCenter(); var offset = center.clone().subtract(origin.toVector()); double distance = offset.length();
|
||||
if (!Double.isFinite(distance) || distance == 0) return distance == 0;
|
||||
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance, org.bukkit.FluidCollisionMode.NEVER, true) == null;
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static double distance(Location origin, Player player) {
|
||||
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return Double.POSITIVE_INFINITY;
|
||||
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ(); return dx * dx + dz * dz;
|
||||
} catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) { var p = definition.parameters();
|
||||
return new Settings(definition.cooldown(), p.get("radius").doubleValue(), p.get("vertical-range").doubleValue(),
|
||||
duration(p.get("duration-seconds")), duration(p.get("pulse-interval-seconds")),
|
||||
p.get("healing-per-pulse"), p.get("damage-reduction-percent")); }
|
||||
private static Duration duration(BigDecimal seconds) { return Duration.ofNanos(seconds.movePointRight(9).longValueExact()); }
|
||||
private static BigDecimal nanos(Duration value) { return BigDecimal.valueOf(value.toNanos()).movePointLeft(9); }
|
||||
private static String seconds(Duration value) { return decimal(nanos(value)); }
|
||||
private static String decimal(double value) { return decimal(BigDecimal.valueOf(value)); }
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; for (var state : List.copyOf(states)) { state.close(); state.execution.finish(); } states.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
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.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.FluidCollisionMode;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.attribute.AttributeModifier;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Fixed spectral War Banner cylinder with strongest damage and one owned knockback-resistance layer. */
|
||||
public final class WarBanner implements AbilityEffect, AutoCloseable {
|
||||
public record Settings(Duration cooldown, double placementRange, double radius, double verticalRange,
|
||||
Duration duration, BigDecimal damageBonusPercent, BigDecimal knockbackResistancePercent) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration);
|
||||
Objects.requireNonNull(damageBonusPercent); Objects.requireNonNull(knockbackResistancePercent);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(placementRange) || placementRange <= 0 || placementRange > 64
|
||||
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|
||||
|| !Double.isFinite(verticalRange) || verticalRange < 0 || verticalRange > 64
|
||||
|| duration.isZero() || duration.isNegative() || duration.compareTo(Duration.ofDays(1)) > 0
|
||||
|| damageBonusPercent.signum() < 0 || damageBonusPercent.compareTo(BigDecimal.valueOf(100)) > 0
|
||||
|| knockbackResistancePercent.signum() < 0
|
||||
|| knockbackResistancePercent.compareTo(BigDecimal.valueOf(100)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid War Banner settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(Duration.ofSeconds(45), 12, 8, 4, Duration.ofSeconds(10),
|
||||
BigDecimal.valueOf(25), BigDecimal.valueOf(50));
|
||||
}
|
||||
}
|
||||
|
||||
private final class Area implements AutoCloseable {
|
||||
final long sequence = ++sequences; final AbilityExecution execution; final Player source;
|
||||
final Location origin; final Settings captured; final Map<UUID, Benefit> recipients = new LinkedHashMap<>();
|
||||
boolean closed;
|
||||
Area(AbilityExecution execution, Location origin, Settings captured) {
|
||||
this.execution = execution; this.source = execution.input().player(); this.origin = origin; this.captured = captured;
|
||||
}
|
||||
void refresh() {
|
||||
if (closed) return;
|
||||
if (!sourceCurrent()) { execution.finish(); return; }
|
||||
var eligible = candidates();
|
||||
for (var entry : List.copyOf(recipients.entrySet())) if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
|
||||
for (var entry : eligible.entrySet()) recipients.computeIfAbsent(entry.getKey(), ignored -> new Benefit(this, entry.getValue()));
|
||||
visual();
|
||||
}
|
||||
boolean sourceCurrent() {
|
||||
return execution.stillCurrent() && sessions.active(source.getUniqueId())
|
||||
.filter(active -> active.equals(execution.input().session()) && sessions.current(active)).isPresent();
|
||||
}
|
||||
boolean eligible(Player player) {
|
||||
if (!sourceCurrent() || !inside(origin, player, captured.radius(), captured.verticalRange())
|
||||
|| !unobstructed(origin, player)) return false;
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
if (active == null) return false;
|
||||
if (player == source) return active.equals(execution.input().session());
|
||||
var sourceParty = parties.partyOf(source.getUniqueId()).orElse(null);
|
||||
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
|
||||
return sourceParty != null && targetParty != null && sourceParty.id().equals(targetParty.id());
|
||||
}
|
||||
Map<UUID, Player> candidates() {
|
||||
List<Entity> nearby;
|
||||
try { nearby = new ArrayList<>(origin.getWorld().getNearbyEntities(origin,
|
||||
captured.radius(), captured.verticalRange(), captured.radius())); }
|
||||
catch (RuntimeException unavailable) { nearby = new ArrayList<>(); }
|
||||
nearby.addFirst(source); var unique = new LinkedHashMap<UUID, Player>(); int inspected = 0;
|
||||
for (var entity : nearby) {
|
||||
if (++inspected > 256) break;
|
||||
if (entity instanceof Player player && eligible(player)) unique.putIfAbsent(player.getUniqueId(), player);
|
||||
}
|
||||
var result = new ArrayList<>(unique.values());
|
||||
result.sort(Comparator.comparingDouble((Player player) -> distance(origin, player)).thenComparing(Player::getUniqueId));
|
||||
if (result.size() > 64) result.subList(64, result.size()).clear();
|
||||
var ordered = new LinkedHashMap<UUID, Player>(); for (var player : result) ordered.put(player.getUniqueId(), player); return ordered;
|
||||
}
|
||||
void visual() {
|
||||
try { origin.getWorld().spawnParticle(Particle.DUST, origin.clone().add(0, 1.5, 0), 16,
|
||||
0.25, 1.5, 0.25, 0, new Particle.DustOptions(Color.RED, 1.5f)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; areas.remove(sequence, this);
|
||||
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private final class Benefit implements AutoCloseable {
|
||||
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance; boolean closed;
|
||||
Benefit(Area area, Player player) {
|
||||
this.area = area; this.player = player;
|
||||
BooleanSupplier current = () -> !closed && area.eligible(player);
|
||||
bonus = bonuses.grant(area.execution, player, area.captured.damageBonusPercent(), current);
|
||||
resistance = resistance(player, area.captured.knockbackResistancePercent(), current);
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
|
||||
closeResource(bonus); closeResource(resistance);
|
||||
}
|
||||
}
|
||||
|
||||
private final class ResistanceGrant implements AutoCloseable {
|
||||
final ResistanceState state; final long sequence = ++resistanceSequences; final BigDecimal percent;
|
||||
final BooleanSupplier current; boolean closed;
|
||||
ResistanceGrant(ResistanceState state, BigDecimal percent, BooleanSupplier current) {
|
||||
this.state = state; this.percent = percent; this.current = current;
|
||||
}
|
||||
boolean valid() { try { return !closed && current.getAsBoolean(); } catch (RuntimeException invalid) { return false; } }
|
||||
@Override public void close() { if (closed) return; closed = true; state.grants.remove(this); state.refresh(); }
|
||||
}
|
||||
private final class ResistanceState {
|
||||
final Player player; final List<ResistanceGrant> grants = new ArrayList<>(); AttributeModifier owned;
|
||||
ResistanceState(Player player) { this.player = player; }
|
||||
void refresh() {
|
||||
AttributeInstance attribute;
|
||||
try { attribute = player.getAttribute(Attribute.KNOCKBACK_RESISTANCE); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (attribute == null) return;
|
||||
ResistanceGrant strongest = null;
|
||||
for (var grant : List.copyOf(grants)) if (grant.valid() && (strongest == null
|
||||
|| grant.percent.compareTo(strongest.percent) > 0
|
||||
|| grant.percent.compareTo(strongest.percent) == 0 && grant.sequence < strongest.sequence)) strongest = grant;
|
||||
double desired = strongest == null ? 0 : strongest.percent.movePointLeft(2).doubleValue();
|
||||
if (owned != null && (!attribute.getModifiers().contains(owned) || Double.compare(owned.getAmount(), desired) != 0)) {
|
||||
attribute.removeModifier(owned); owned = null;
|
||||
}
|
||||
if (strongest != null && owned == null) {
|
||||
var key = new NamespacedKey("fantasystick", "war_banner_" + player.getUniqueId());
|
||||
for (var modifier : List.copyOf(attribute.getModifiers())) if (modifier.getKey().equals(key)) attribute.removeModifier(modifier);
|
||||
owned = new AttributeModifier(key, desired, AttributeModifier.Operation.ADD_NUMBER); attribute.addModifier(owned);
|
||||
}
|
||||
if (strongest == null) { resistances.remove(player.getUniqueId(), this); owned = null; }
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final EquipmentSessions sessions; private final PartyRoster parties; private final OutgoingDamageBonus bonuses;
|
||||
private final Map<Long, Area> areas = new java.util.HashMap<>(); private final Map<UUID, ResistanceState> resistances = new java.util.HashMap<>();
|
||||
private long sequences; private long resistanceSequences; private org.bukkit.scheduler.BukkitTask sweepTask; private boolean closed;
|
||||
|
||||
public WarBanner(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties); this.bonuses = Objects.requireNonNull(bonuses);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin); if (closed || sweepTask != null) throw new IllegalStateException("War Banner sweep already installed or closed");
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
public void sweep() {
|
||||
if (closed) return; int checked = 0;
|
||||
for (var area : List.copyOf(areas.values())) { area.refresh(); if (++checked == 64) break; }
|
||||
for (var state : List.copyOf(resistances.values())) state.refresh();
|
||||
}
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("war_banner", "War Banner", Material.RED_BANNER,
|
||||
List.of("Plant a spectral banner within " + decimal(current.placementRange()) + " blocks for " + seconds(current.duration()) + " seconds.",
|
||||
"Party members in its " + decimal(current.radius()) + "-block area gain " + decimal(current.damageBonusPercent())
|
||||
+ "% combat damage and " + decimal(current.knockbackResistancePercent()) + "% knockback resistance."),
|
||||
"Use while looking at the solid top surface where the fixed banner should stand; copies do not stack.",
|
||||
Map.of(Stat.STR, BigInteger.valueOf(22), Stat.CHR, BigInteger.valueOf(22), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("placement-range", BigDecimal.valueOf(current.placementRange()).stripTrailingZeros(),
|
||||
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
|
||||
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
|
||||
"duration-seconds", nanos(current.duration()), "damage-bonus-percent", current.damageBonusPercent(),
|
||||
"knockback-resistance-percent", current.knockbackResistancePercent()), AbilityDefinition.TargetPolicy.AREA, this);
|
||||
}
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
return !closed && input.target() == null && placement(input.player(), settings.placementRange()) != null;
|
||||
}
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return; var captured = from(execution.definition());
|
||||
var origin = placement(execution.input().player(), captured.placementRange());
|
||||
if (origin == null) { execution.finish(); return; }
|
||||
var area = new Area(execution, origin, captured); areas.put(area.sequence, area); execution.own(area);
|
||||
area.refresh(); execution.later(captured.duration(), execution::finish);
|
||||
}
|
||||
|
||||
private AutoCloseable resistance(Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
var state = resistances.computeIfAbsent(player.getUniqueId(), ignored -> new ResistanceState(player));
|
||||
var grant = new ResistanceGrant(state, percent, current); state.grants.add(grant); state.refresh(); return grant;
|
||||
}
|
||||
private static Location placement(Player player, double range) {
|
||||
try { var eye = player.getEyeLocation(); var hit = player.getWorld().rayTraceBlocks(eye, eye.getDirection(), range,
|
||||
FluidCollisionMode.NEVER, true);
|
||||
if (hit == null || hit.getHitBlock() == null || hit.getHitBlockFace() != BlockFace.UP
|
||||
|| !hit.getHitBlock().getType().isSolid()) return null;
|
||||
return hit.getHitBlock().getLocation().add(0.5, 1, 0.5);
|
||||
} catch (RuntimeException invalid) { return null; }
|
||||
}
|
||||
private static boolean inside(Location origin, Player player, double radius, double vertical) {
|
||||
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return false;
|
||||
double dx=at.getX()-origin.getX(), dz=at.getZ()-origin.getZ();
|
||||
return dx*dx+dz*dz<=radius*radius && Math.abs(at.getY()-origin.getY())<=vertical;
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static boolean unobstructed(Location origin, Player player) {
|
||||
try { var world=origin.getWorld(); if(world==null)return false;var offset=player.getBoundingBox().getCenter().subtract(origin.toVector());
|
||||
double distance=offset.length();if(!Double.isFinite(distance)||distance==0)return distance==0;
|
||||
return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;
|
||||
} catch(RuntimeException invalid){return false;}
|
||||
}
|
||||
private static double distance(Location origin, Player player) {
|
||||
try { var at=player.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;
|
||||
double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;
|
||||
} catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) { var p=definition.parameters();return new Settings(definition.cooldown(),
|
||||
p.get("placement-range").doubleValue(),p.get("radius").doubleValue(),p.get("vertical-range").doubleValue(),
|
||||
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-bonus-percent"),p.get("knockback-resistance-percent")); }
|
||||
private static BigDecimal nanos(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9);}
|
||||
private static String seconds(Duration value){return decimal(nanos(value));}
|
||||
private static String decimal(double value){return decimal(BigDecimal.valueOf(value));}
|
||||
private static String decimal(BigDecimal value){return value.stripTrailingZeros().toPlainString();}
|
||||
private static void closeResource(AutoCloseable resource){try{resource.close();}catch(Exception impossible){throw new IllegalStateException(impossible);}}
|
||||
@Override public void close(){if(closed)return;closed=true;if(sweepTask!=null)sweepTask.cancel();for(var area:List.copyOf(areas.values()))area.execution.finish();areas.clear();for(var state:List.copyOf(resistances.values())){for(var grant:List.copyOf(state.grants))grant.close();}resistances.clear();}
|
||||
}
|
||||
@@ -9,6 +9,15 @@ public final class BookPagination {
|
||||
private BookPagination() { }
|
||||
|
||||
public static List<Component> pages(String text) {
|
||||
var lines = lines(text);
|
||||
var pages = new ArrayList<Component>();
|
||||
for (int start = 0; start < lines.size(); start += 13) {
|
||||
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
|
||||
}
|
||||
return List.copyOf(pages);
|
||||
}
|
||||
|
||||
static List<String> lines(String text) {
|
||||
var lines = new ArrayList<String>();
|
||||
var line = new StringBuilder();
|
||||
int width = 0;
|
||||
@@ -28,10 +37,6 @@ public final class BookPagination {
|
||||
line.appendCodePoint(codePoint); width += advance;
|
||||
}
|
||||
lines.add(line.toString());
|
||||
var pages = new ArrayList<Component>();
|
||||
for (int start = 0; start < lines.size(); start += 13) {
|
||||
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
|
||||
}
|
||||
return List.copyOf(pages);
|
||||
return List.copyOf(lines);
|
||||
}
|
||||
}
|
||||
@@ -27,23 +27,28 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private final Consumer<Failure> failures;
|
||||
private final games.dmg.fantasystick.ability.AbilityRegistry abilities;
|
||||
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
|
||||
private final games.dmg.fantasystick.passive.PassiveRegistry passives;
|
||||
private final games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
|
||||
private final 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 int VOLUME_SIZE = 30; // Reserve room for linked ability details, navigation and party pages.
|
||||
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
|
||||
private static final net.kyori.adventure.key.Key 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 net.kyori.adventure.key.Key PASSIVE_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "passive_unlock");
|
||||
private static final net.kyori.adventure.key.Key PASSIVE_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "passive_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 static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})\"");
|
||||
private record View(String nonce, ControlInput input, int previous, int next) { }
|
||||
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
|
||||
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
|
||||
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
|
||||
private final Map<UUID, View> views = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, StatView> statViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, AbilityView> abilityViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, PassiveView> passiveViews = new ConcurrentHashMap<>();
|
||||
|
||||
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
@@ -51,7 +56,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
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);
|
||||
new games.dmg.fantasystick.ability.AbilityRegistry(), null,
|
||||
new games.dmg.fantasystick.passive.PassiveRegistry(), null);
|
||||
}
|
||||
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
@@ -60,6 +66,18 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
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, rules, sessions, nextTick, failures, parties, server, names, abilities, abilityEngine,
|
||||
new games.dmg.fantasystick.passive.PassiveRegistry(), 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,
|
||||
games.dmg.fantasystick.passive.PassiveRegistry passives,
|
||||
games.dmg.fantasystick.passive.PassiveEngine passiveEngine) {
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.rules = Objects.requireNonNull(rules);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
@@ -67,6 +85,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
this.abilities = Objects.requireNonNull(abilities);
|
||||
this.abilityEngine = abilityEngine;
|
||||
this.passives = Objects.requireNonNull(passives);
|
||||
this.passiveEngine = passiveEngine;
|
||||
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
|
||||
}
|
||||
|
||||
@@ -80,6 +100,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
views.remove(owner);
|
||||
statViews.remove(owner);
|
||||
abilityViews.remove(owner);
|
||||
passiveViews.remove(owner);
|
||||
Object ticket = new Object();
|
||||
pending.put(owner, ticket);
|
||||
characters.find(input.session().character()).whenComplete((record, failure) -> {
|
||||
@@ -107,47 +128,72 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
|
||||
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
|
||||
var first = new StringBuilder(record.name()).append("\n\n");
|
||||
for (var stat : Stat.values()) first.append(stat).append(": ").append(record.stats().get(stat)).append('\n');
|
||||
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
|
||||
String xp = record.level() >= rules.maximumLevel() ? "MAX"
|
||||
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
|
||||
String learned = record.learned().isEmpty() ? "none" : String.join(", ", record.learned().stream().sorted().toList());
|
||||
String second = "Level: " + record.level() + "\nXP: " + xp + "\nUnspent points: " + record.unspentPoints()
|
||||
+ "\nHealth: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
|
||||
+ "/" + maximum.stripTrailingZeros().toPlainString()
|
||||
+ "\n\nAbilities: " + learned;
|
||||
var item = new ItemStack(Material.WRITTEN_BOOK);
|
||||
var meta = (BookMeta) item.getItemMeta();
|
||||
meta.setTitle("Character");
|
||||
meta.setAuthor("Fantasy Stick");
|
||||
var all = BookPagination.pages(first + "\n" + second);
|
||||
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 allOverview = overviewPages(record, input, statView);
|
||||
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
|
||||
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
|
||||
var pages = new java.util.ArrayList<Component>(allOverview.subList(start, end));
|
||||
if (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
|
||||
|
||||
var definitions = abilities.definitions().stream()
|
||||
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::name)
|
||||
.thenComparing(games.dmg.fantasystick.ability.AbilityDefinition::id))
|
||||
.toList();
|
||||
var passiveDefinitions = passives.definitions().stream()
|
||||
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.passive.PassiveDefinition::name)
|
||||
.thenComparing(games.dmg.fantasystick.passive.PassiveDefinition::id))
|
||||
.toList();
|
||||
if ((!definitions.isEmpty() || !passiveDefinitions.isEmpty()) && end == allOverview.size()) {
|
||||
var abilityView = new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
|
||||
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 (!definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
|
||||
var passiveView = new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
|
||||
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
|
||||
if (!passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
|
||||
var details = definitions.stream().map(definition -> abilityPages(record, definition, abilityView)).toList();
|
||||
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.passive.PassiveDefinition::id,
|
||||
games.dmg.fantasystick.passive.PassiveDefinition::name));
|
||||
var passiveDetails = passiveDefinitions.stream()
|
||||
.map(definition -> passiveDetailPages(record, definition, passiveNames, passiveView)).toList();
|
||||
var placeholderTargets = definitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.ability.AbilityDefinition::id, ignored -> 1));
|
||||
var passivePlaceholderTargets = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.passive.PassiveDefinition::id, ignored -> 1));
|
||||
int activeCount = activePages(record, definitions, placeholderTargets).size();
|
||||
int passiveCount = passiveSlotPages(record, passiveDefinitions, passivePlaceholderTargets).size();
|
||||
int indexCount = indexPages(definitions, placeholderTargets).size();
|
||||
int passiveIndexCount = passiveIndexPages(passiveDefinitions, passivePlaceholderTargets).size();
|
||||
var targets = new java.util.HashMap<String, Integer>();
|
||||
int target = pages.size() + activeCount + passiveCount + indexCount + passiveIndexCount + 1;
|
||||
for (int index = 0; index < definitions.size(); index++) {
|
||||
targets.put(definitions.get(index).id(), target);
|
||||
target += details.get(index).size();
|
||||
}
|
||||
var passiveTargets = new java.util.HashMap<String, Integer>();
|
||||
for (int index = 0; index < passiveDefinitions.size(); index++) {
|
||||
passiveTargets.put(passiveDefinitions.get(index).id(), target);
|
||||
target += passiveDetails.get(index).size();
|
||||
}
|
||||
pages.addAll(activePages(record, definitions, targets));
|
||||
pages.addAll(passiveSlotPages(record, passiveDefinitions, passiveTargets));
|
||||
pages.addAll(indexPages(definitions, targets));
|
||||
pages.addAll(passiveIndexPages(passiveDefinitions, passiveTargets));
|
||||
details.forEach(pages::addAll);
|
||||
passiveDetails.forEach(pages::addAll);
|
||||
}
|
||||
if (all.size() > VOLUME_SIZE) {
|
||||
if (allOverview.size() > VOLUME_SIZE) {
|
||||
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
|
||||
end < all.size() ? end : -1);
|
||||
end < allOverview.size() ? end : -1);
|
||||
views.put(input.session().player(), view);
|
||||
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + all.size());
|
||||
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + allOverview.size());
|
||||
if (view.previous() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Previous pages", view, 0));
|
||||
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
|
||||
pages.add(navigation);
|
||||
@@ -158,6 +204,122 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
return item;
|
||||
}
|
||||
|
||||
private java.util.List<Component> overviewPages(CharacterRecord record, ControlInput input, StatView view) {
|
||||
var builder = new PageBuilder();
|
||||
builder.text(record.name());
|
||||
builder.blank();
|
||||
for (var stat : Stat.values()) {
|
||||
Component action = record.unspentPoints().signum() > 0
|
||||
? statButton(stat, view) : null;
|
||||
builder.inline(stat + ": " + record.stats().get(stat), action);
|
||||
}
|
||||
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
|
||||
String xp = record.level() >= rules.maximumLevel() ? "MAX"
|
||||
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
|
||||
builder.text("Level: " + record.level());
|
||||
builder.text("XP: " + xp);
|
||||
builder.text("Unspent points: " + record.unspentPoints());
|
||||
builder.text("Health: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
|
||||
+ "/" + maximum.stripTrailingZeros().toPlainString());
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private static Component statButton(Stat stat, StatView view) {
|
||||
return 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("\"" + view.nonce() + ":" + stat + "\"")));
|
||||
}
|
||||
|
||||
private static java.util.List<Component> activePages(CharacterRecord record,
|
||||
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
|
||||
var byId = definitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.ability.AbilityDefinition::id, java.util.function.Function.identity()));
|
||||
var builder = new PageBuilder();
|
||||
builder.text("Active abilities"); builder.blank();
|
||||
for (int slot = 0; slot < record.loadout().size(); slot++) {
|
||||
String id = record.loadout().get(slot);
|
||||
var definition = byId.get(id);
|
||||
if (id.isEmpty()) builder.text((slot + 3) + ": No Ability");
|
||||
else if (definition == null) builder.text((slot + 3) + ": " + id);
|
||||
else builder.link((slot + 3) + ": ", definition.name(), targets.get(id));
|
||||
}
|
||||
var unassigned = record.learned().stream().filter(id -> !record.loadout().contains(id)).sorted().toList();
|
||||
if (!unassigned.isEmpty()) {
|
||||
builder.blank(); builder.text("Unassigned learned:");
|
||||
for (var id : unassigned) {
|
||||
var definition = byId.get(id);
|
||||
if (definition == null) builder.text(id);
|
||||
else builder.link("", definition.name(), targets.get(id));
|
||||
}
|
||||
}
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private static java.util.List<Component> passiveSlotPages(CharacterRecord record,
|
||||
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> definitions, Map<String, Integer> targets) {
|
||||
var byId = definitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.passive.PassiveDefinition::id, java.util.function.Function.identity()));
|
||||
var builder = new PageBuilder(); builder.text("Passive abilities"); builder.blank();
|
||||
for (int slot = 0; slot < record.passives().size(); slot++) {
|
||||
String id = record.passives().get(slot); var definition = byId.get(id);
|
||||
if (id.isEmpty()) builder.text((slot + 1) + ": No Passive");
|
||||
else if (definition == null) builder.text((slot + 1) + ": " + id);
|
||||
else builder.link((slot + 1) + ": ", definition.name(), targets.get(id));
|
||||
}
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private static java.util.List<Component> indexPages(
|
||||
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
|
||||
var pages = new java.util.ArrayList<Component>();
|
||||
for (int start = 0; start < definitions.size(); start += 11) {
|
||||
var builder = new PageBuilder(); builder.text("Ability index"); builder.blank();
|
||||
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
|
||||
builder.link("", definition.name(), targets.get(definition.id()));
|
||||
}
|
||||
pages.addAll(builder.pages());
|
||||
}
|
||||
return java.util.List.copyOf(pages);
|
||||
}
|
||||
|
||||
private static java.util.List<Component> passiveIndexPages(
|
||||
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> definitions, Map<String, Integer> targets) {
|
||||
var pages = new java.util.ArrayList<Component>();
|
||||
for (int start = 0; start < definitions.size(); start += 11) {
|
||||
var builder = new PageBuilder(); builder.text("Passive index"); builder.blank();
|
||||
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
|
||||
builder.link("", definition.name(), targets.get(definition.id()));
|
||||
}
|
||||
pages.addAll(builder.pages());
|
||||
}
|
||||
return java.util.List.copyOf(pages);
|
||||
}
|
||||
|
||||
private static java.util.List<Component> passiveDetailPages(CharacterRecord record,
|
||||
games.dmg.fantasystick.passive.PassiveDefinition definition, Map<String, String> names, PassiveView view) {
|
||||
String requirements = definition.requirements().entrySet().stream().sorted(Map.Entry.comparingByKey())
|
||||
.map(entry -> entry.getKey() + " " + entry.getValue()).collect(java.util.stream.Collectors.joining(", "));
|
||||
if (!definition.prerequisites().isEmpty()) {
|
||||
String prerequisiteNames = definition.prerequisites().stream().map(id -> names.getOrDefault(id, id)).sorted()
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
requirements = requirements.isEmpty() ? prerequisiteNames : prerequisiteNames + ", " + requirements;
|
||||
}
|
||||
boolean learned = record.passives().contains(definition.id());
|
||||
boolean prerequisites = record.passives().containsAll(definition.prerequisites());
|
||||
boolean stats = definition.requirements().entrySet().stream()
|
||||
.allMatch(entry -> record.stats().get(entry.getKey()).compareTo(entry.getValue()) >= 0);
|
||||
boolean points = record.unspentPoints().compareTo(definition.pointCost()) >= 0;
|
||||
boolean slot = record.passives().contains("");
|
||||
String state = learned ? "Learned" : !prerequisites || !stats ? "Unavailable: requirements"
|
||||
: !points ? "Unavailable: points" : !slot ? "Unavailable: passive slots" : "Available";
|
||||
var builder = new PageBuilder(); builder.text(definition.name()); builder.text(requirements);
|
||||
if (learned) builder.component(passiveButton("Unlearn", PASSIVE_UNLEARN, view, definition.id()));
|
||||
else if (prerequisites && stats && points && slot) builder.component(passiveButton("Learn", PASSIVE_UNLOCK, view, definition.id()));
|
||||
else builder.text(state);
|
||||
builder.blank(); definition.description().forEach(builder::text); builder.blank(); builder.text(state);
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -166,29 +328,70 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
boolean learned = record.learned().contains(definition.id());
|
||||
boolean stats = definition.requirements().entrySet().stream()
|
||||
.allMatch(entry -> record.stats().get(entry.getKey()).compareTo(entry.getValue()) >= 0);
|
||||
boolean points = record.unspentPoints().compareTo(definition.pointCost()) >= 0;
|
||||
boolean slot = record.loadout().contains("");
|
||||
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()
|
||||
: !points ? "Unavailable: points" : !slot ? "Unavailable: slots" : "Available";
|
||||
var builder = new PageBuilder();
|
||||
builder.text(definition.name());
|
||||
builder.text(requirements);
|
||||
if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
|
||||
else if (stats && points && slot) builder.component(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id()));
|
||||
else builder.text(state);
|
||||
builder.blank();
|
||||
definition.lore().forEach(builder::text);
|
||||
builder.blank(); builder.text(definition.instructions());
|
||||
builder.text("Cooldown: " + durationSeconds(definition.cooldown()) + "s");
|
||||
if (!definition.parameters().isEmpty()) builder.text("Parameters: " + 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));
|
||||
.collect(java.util.stream.Collectors.joining(", ")));
|
||||
builder.text(state);
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private static final class PageBuilder {
|
||||
private final java.util.List<Component> lines = new java.util.ArrayList<>();
|
||||
void blank() { lines.add(Component.empty()); }
|
||||
void text(String text) { BookPagination.lines(text).forEach(line -> lines.add(Component.text(line))); }
|
||||
void component(Component component) { lines.add(component); }
|
||||
void inline(String text, Component action) {
|
||||
var wrapped = BookPagination.lines(text);
|
||||
for (int index = 0; index < wrapped.size() - 1; index++) lines.add(Component.text(wrapped.get(index)));
|
||||
var last = Component.text(wrapped.getLast());
|
||||
if (action != null && displayWidth(wrapped.getLast()) + 6 + 48 <= 108) {
|
||||
lines.add(last.append(Component.space()).append(action));
|
||||
} else {
|
||||
lines.add(last);
|
||||
if (action != null) lines.add(action);
|
||||
}
|
||||
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);
|
||||
void link(String prefix, String label, int page) {
|
||||
if (displayWidth(prefix + label) <= 108) {
|
||||
lines.add(Component.text(prefix).append(Component.text(label, net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(page))));
|
||||
return;
|
||||
}
|
||||
for (var part : BookPagination.lines(prefix + label)) {
|
||||
lines.add(Component.text(part, net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(page)));
|
||||
}
|
||||
}
|
||||
private static int displayWidth(String text) {
|
||||
return text.codePoints().map(codePoint -> codePoint < 128 ? 6 : 9).sum();
|
||||
}
|
||||
java.util.List<Component> pages() {
|
||||
var pages = new java.util.ArrayList<Component>();
|
||||
for (int start = 0; start < lines.size(); start += 13) {
|
||||
Component page = Component.empty();
|
||||
for (int index = start; index < Math.min(start + 13, lines.size()); index++) {
|
||||
if (index > start) page = page.append(Component.newline());
|
||||
page = page.append(lines.get(index));
|
||||
}
|
||||
pages.add(page);
|
||||
}
|
||||
if (pages.isEmpty()) pages.add(Component.empty());
|
||||
return java.util.List.copyOf(pages);
|
||||
}
|
||||
}
|
||||
|
||||
private static String durationSeconds(java.time.Duration duration) {
|
||||
@@ -197,8 +400,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
|
||||
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) + "\"";
|
||||
String ability) {
|
||||
String payload = "\"" + view.nonce() + ":" + ability + "\"";
|
||||
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 passiveButton(String label, net.kyori.adventure.key.Key action, PassiveView view,
|
||||
String passive) {
|
||||
String payload = "\"" + view.nonce() + ":" + passive + "\"";
|
||||
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)));
|
||||
@@ -292,9 +503,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
@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
|
||||
if (closed || abilityEngine == null || !unlock && !unlearn
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|
||||
|| event.getTag() == null) return;
|
||||
var player = connection.getPlayer();
|
||||
@@ -304,12 +514,10 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
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());
|
||||
: abilityEngine.unlearn(view.input(), ability, view.revision());
|
||||
operation.whenComplete((mutation, failure) -> {
|
||||
if (closed || !view.input().stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
@@ -317,22 +525,66 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
player.sendMessage("Ability change could not be confirmed; reopen the character after session recovery.");
|
||||
return;
|
||||
}
|
||||
player.sendMessage(abilityMessage(unlock, unlearn, ability, mutation.status()));
|
||||
player.sendMessage(abilityMessage(unlock, ability, mutation.status()));
|
||||
load(view.input(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
private static String abilityMessage(boolean unlock, boolean unlearn, String ability,
|
||||
@org.bukkit.event.EventHandler
|
||||
public void passiveAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
boolean unlock = PASSIVE_UNLOCK.equals(event.getIdentifier());
|
||||
boolean unlearn = PASSIVE_UNLEARN.equals(event.getIdentifier());
|
||||
if (closed || passiveEngine == null || !unlock && !unlearn
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|
||||
|| event.getTag() == null) return;
|
||||
var player = connection.getPlayer(); var view = passiveViews.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))
|
||||
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
String passive = match.group(2);
|
||||
var operation = unlock ? passiveEngine.purchase(view.input(), passive, view.revision())
|
||||
: passiveEngine.unlearn(view.input(), passive, view.revision());
|
||||
operation.whenComplete((mutation, failure) -> {
|
||||
if (closed || !view.input().stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
report(view.input(), failure);
|
||||
player.sendMessage("Passive change could not be confirmed; reopen the character after session recovery.");
|
||||
return;
|
||||
}
|
||||
player.sendMessage(passiveMessage(unlock, passive, mutation.status()));
|
||||
load(view.input(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
private static String passiveMessage(boolean unlock, String passive,
|
||||
CharacterStorage.PassiveMutationStatus status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> unlock ? "Learned " + passive + " in the first available passive slot."
|
||||
: "Unlearned " + passive + " and refunded 1 point.";
|
||||
case REQUIREMENTS -> "The passive prerequisites or purchased base-stat requirements are not met.";
|
||||
case NO_POINTS -> "This character does not have enough unspent points.";
|
||||
case PASSIVE_SLOTS_FULL -> "All three passive slots are occupied; unlearn a passive before learning another.";
|
||||
case ALREADY_LEARNED -> "This character already knows " + passive + ".";
|
||||
case NOT_LEARNED -> "This character no longer knows " + passive + ".";
|
||||
case REQUIRED_BY_LEARNED -> "Another learned passive requires " + passive + ".";
|
||||
case STALE -> "That character page is stale; reopen it before changing passives.";
|
||||
case UNAVAILABLE -> "That character or passive is unavailable.";
|
||||
};
|
||||
}
|
||||
|
||||
private static String abilityMessage(boolean unlock, 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 APPLIED -> unlock ? "Learned " + ability + " and assigned it to the first available slot."
|
||||
: "Unlearned " + ability + " and refunded 1 point.";
|
||||
case UNCHANGED -> ability + " is unchanged.";
|
||||
case REQUIREMENTS -> "The purchased base-stat requirements are not met.";
|
||||
case NO_POINTS -> "This character does not have enough unspent points.";
|
||||
case LOADOUT_FULL -> "All seven ability slots are occupied; unlearn an ability before learning another.";
|
||||
case ALREADY_LEARNED -> "This character already knows " + ability + ".";
|
||||
case NOT_LEARNED -> unlearn ? "This character no longer knows " + ability + "."
|
||||
: "Learn " + ability + " before assigning it.";
|
||||
case NOT_LEARNED -> "This character no longer knows " + ability + ".";
|
||||
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.";
|
||||
@@ -349,10 +601,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
views.remove(owner);
|
||||
statViews.remove(owner);
|
||||
abilityViews.remove(owner);
|
||||
passiveViews.remove(owner);
|
||||
partyControls.forget(owner);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); partyControls.close();
|
||||
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -17,7 +18,9 @@ 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 {
|
||||
public static final NamespacedKey COOLDOWN_MODEL = new NamespacedKey("fantasystick", "cooldown_control");
|
||||
private final games.dmg.fantasystick.ability.AbilityRegistry registry;
|
||||
private final Clock clock;
|
||||
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");
|
||||
@@ -52,9 +55,13 @@ public final class SessionControls {
|
||||
|
||||
public record Identity(UUID owner, UUID session, int slot) { }
|
||||
|
||||
public SessionControls() { this(new games.dmg.fantasystick.ability.AbilityRegistry()); }
|
||||
public SessionControls() { this(new games.dmg.fantasystick.ability.AbilityRegistry(), Clock.systemUTC()); }
|
||||
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry) {
|
||||
this(registry, Clock.systemUTC());
|
||||
}
|
||||
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry, Clock clock) {
|
||||
this.registry = Objects.requireNonNull(registry);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
private Icon icon(String ability) {
|
||||
@@ -71,24 +78,29 @@ public final class SessionControls {
|
||||
Objects.requireNonNull(owner);
|
||||
Objects.requireNonNull(session);
|
||||
var items = new ItemStack[9];
|
||||
var now = clock.instant();
|
||||
for (int slot = 0; slot < items.length; slot++) {
|
||||
String ability = slot < 2 ? "" : character.loadout().get(slot - 2);
|
||||
Icon abilityIcon = ability.isEmpty() ? null : icon(ability);
|
||||
var deadline = ability.isEmpty() ? null : character.cooldowns().get(ability);
|
||||
boolean cooling = abilityIcon != null && deadline != null && now.isBefore(deadline);
|
||||
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")
|
||||
: abilityIcon == null ? new Icon(Material.PAPER, "Unavailable ability: " + ability) : abilityIcon;
|
||||
: abilityIcon == null ? new Icon(Material.PAPER, "Unavailable ability: " + ability)
|
||||
: cooling ? new Icon(Material.GRAY_STAINED_GLASS_PANE, abilityIcon.name() + " — Cooling Down") : abilityIcon;
|
||||
var item = new ItemStack(icon.material());
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(icon.name()));
|
||||
meta.setMaxStackSize(1);
|
||||
if (cooling) meta.setItemModel(COOLDOWN_MODEL);
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
data.set(OWNER, PersistentDataType.STRING, owner.toString());
|
||||
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" : abilityIcon != null ? "ability" : "unavailable");
|
||||
: ability.isEmpty() ? "empty" : abilityIcon == null ? "unavailable" : cooling ? "cooldown" : "ability");
|
||||
if (!ability.isEmpty()) data.set(ABILITY, PersistentDataType.STRING, ability);
|
||||
item.setItemMeta(meta);
|
||||
items[slot] = item;
|
||||
@@ -123,7 +135,8 @@ public final class SessionControls {
|
||||
public Optional<String> ability(ItemStack item) {
|
||||
if (identify(item).isEmpty()) return Optional.empty();
|
||||
var data = item.getPersistentDataContainer();
|
||||
return "ability".equals(data.get(KIND, PersistentDataType.STRING))
|
||||
String kind = data.get(KIND, PersistentDataType.STRING);
|
||||
return kind.equals("ability") || kind.equals("cooldown")
|
||||
? Optional.of(data.get(ABILITY, PersistentDataType.STRING)) : Optional.empty();
|
||||
}
|
||||
|
||||
@@ -137,6 +150,7 @@ public final class SessionControls {
|
||||
if (ability.isBlank()) return false;
|
||||
Icon icon = icon(ability);
|
||||
return kind.equals("ability") ? icon != null && icon.material() == material
|
||||
: kind.equals("cooldown") ? icon != null && material == Material.GRAY_STAINED_GLASS_PANE
|
||||
: kind.equals("unavailable") && icon == null && material == Material.PAPER;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,14 @@ public final class TailBooks {
|
||||
section.append(deadline == null ? "\nCooldown: none" : "\nCooldown until:\n" + deadline);
|
||||
pages.addAll(BookPagination.pages(section.toString()));
|
||||
});
|
||||
if (character.passives().stream().allMatch(String::isEmpty)) {
|
||||
pages.addAll(BookPagination.pages("Passives: None"));
|
||||
} else {
|
||||
for (int slot = 0; slot < character.passives().size(); slot++) {
|
||||
String passive = character.passives().get(slot);
|
||||
if (!passive.isEmpty()) pages.addAll(BookPagination.pages("Passive: " + passive + "\nSlot: " + (slot + 1)));
|
||||
}
|
||||
}
|
||||
// Unlearned/unknown saved cooldowns remain readable without pretending they are learned abilities.
|
||||
character.cooldowns().keySet().stream().filter(id -> !character.learned().contains(id)).sorted().forEach(id ->
|
||||
pages.addAll(BookPagination.pages("Saved cooldown\n" + id + "\nUntil:\n" + character.cooldowns().get(id))));
|
||||
|
||||
@@ -100,26 +100,23 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
contributions.support(target, aided.character(), supportingCharacter, clock.instant());
|
||||
}
|
||||
|
||||
/** Records effective healing; any directly contributed target can qualify it within ten seconds. */
|
||||
public void support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
|
||||
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
|
||||
if (closed || amount.signum() <= 0 || !sessions.current(aided) || supportingCharacter.equals(aided.character())) return;
|
||||
contributions.support(aided.character(), supportingCharacter, clock.instant());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (closed || event.getFinalDamage() <= 0) return;
|
||||
var origin = source(event);
|
||||
if (closed || games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()
|
||||
|| event.getFinalDamage() <= 0) return;
|
||||
var origin = sources.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;
|
||||
@@ -205,6 +202,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
|
||||
public void retryPending() {
|
||||
if (closed) return;
|
||||
contributions.sweep(clock.instant());
|
||||
for (int attempted = 0; attempted < 64; attempted++) {
|
||||
UUID id = retries.poll();
|
||||
if (id == null) break;
|
||||
|
||||
@@ -10,9 +10,9 @@ import org.bukkit.entity.Projectile;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Source identity travels with delayed native entities, never with a replacement sheet or shooter. */
|
||||
final class CombatSources {
|
||||
public final class CombatSources {
|
||||
static final NamespacedKey ORIGIN = new NamespacedKey("fantasystick", "combat_origin_v1");
|
||||
record Origin(UUID player, UUID session, UUID character) {
|
||||
public record Origin(UUID player, UUID session, UUID character) {
|
||||
String encode() { return player + "/" + (session == null ? "-/-" : session + "/" + character); }
|
||||
static Origin decode(String value) {
|
||||
if (value == null || value.length() > 110) return null;
|
||||
@@ -26,15 +26,27 @@ final class CombatSources {
|
||||
}
|
||||
}
|
||||
private final EquipmentSessions sessions;
|
||||
CombatSources(EquipmentSessions sessions) { this.sessions = Objects.requireNonNull(sessions); }
|
||||
public CombatSources(EquipmentSessions sessions) { this.sessions = Objects.requireNonNull(sessions); }
|
||||
Origin player(UUID player) {
|
||||
return sessions.active(player).map(active -> new Origin(player, active.session(), active.character()))
|
||||
.orElseGet(() -> new Origin(player, null, null));
|
||||
}
|
||||
boolean current(Origin origin) {
|
||||
public boolean current(Origin origin) {
|
||||
return origin.equals(player(origin.player())) && (origin.session() == null
|
||||
|| sessions.current(new EquipmentSessions.Active(origin.player(), origin.session(), origin.character())));
|
||||
}
|
||||
public Origin source(org.bukkit.event.entity.EntityDamageEvent event) {
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
if (ongoing != null) return ongoing.source();
|
||||
}
|
||||
var origin = read(event.getDamageSource().getDirectEntity());
|
||||
if (origin == null && event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity) {
|
||||
origin = read(byEntity.getDamager());
|
||||
}
|
||||
if (origin == null) origin = read(event.getDamageSource().getCausingEntity());
|
||||
return origin;
|
||||
}
|
||||
Origin read(Entity entity) { return read(entity, 0); }
|
||||
private Origin read(Entity entity, int depth) {
|
||||
if (entity == null || depth >= 8) return null;
|
||||
|
||||
@@ -66,6 +66,7 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()) return;
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
if (ongoing != null && (!sources.current(ongoing.target()) || blocked(ongoing.source(), target))) {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.party.CombatSources;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
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.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
import org.bukkit.event.entity.EntityRemoveEvent;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
|
||||
/** Consume-once final-blow attribution and persisted native healing for Health Steal. */
|
||||
public final class HealthSteal implements Listener, AutoCloseable {
|
||||
public static final String ID = "health_steal";
|
||||
private static final int MAX_PENDING_PER_CHARACTER = 64;
|
||||
private static final int MAX_CONSUMED_EVENTS = 1_024;
|
||||
public record Settings(BigInteger healing) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(healing);
|
||||
if (healing.signum() < 0 || healing.compareTo(BigInteger.valueOf(1_024)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Health Steal healing");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.ONE); }
|
||||
}
|
||||
public record Failure(UUID character, Throwable cause) { }
|
||||
private record Candidate(LivingEntity target, CombatSources.Origin origin) { }
|
||||
private record Heal(EquipmentSessions.Active active, double amount) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<EntityRegainHealthEvent> healingEvents;
|
||||
private final Supplier<BigDecimal> nativeLimit;
|
||||
private final Consumer<Failure> failures;
|
||||
private final CombatSources sources;
|
||||
private final Map<UUID, Candidate> lethal = new java.util.HashMap<>();
|
||||
private final Set<PlayerDeathEvent> fantasyDefeats = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private final Set<EntityDeathEvent> consumed = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private final ArrayDeque<EntityDeathEvent> consumedOrder = new ArrayDeque<>();
|
||||
private final Map<UUID, ArrayDeque<Heal>> pending = new java.util.HashMap<>();
|
||||
private volatile Settings settings;
|
||||
private boolean closed;
|
||||
|
||||
public HealthSteal(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
|
||||
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures) {
|
||||
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);
|
||||
this.failures = Objects.requireNonNull(failures); sources = new CombatSources(sessions);
|
||||
}
|
||||
|
||||
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
|
||||
public BigInteger healing() { return settings.healing(); }
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Health Steal",
|
||||
Map.of(Stat.STR, BigInteger.valueOf(14), Stat.CON, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE,
|
||||
List.of("Immediately restore " + healing() + " health point" + (healing().equals(BigInteger.ONE) ? "" : "s")
|
||||
+ " when this active character's accepted damage lands a qualifying final blow.",
|
||||
"Passive mobs qualify; assists, ordinary players and repeated defeat callbacks do not."));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (closed || !(event.getEntity() instanceof LivingEntity target)) return;
|
||||
UUID targetId = target.getUniqueId();
|
||||
if (event.isCancelled() || event.getFinalDamage() <= 0) { lethal.remove(targetId); return; }
|
||||
var origin = sources.source(event);
|
||||
double remaining = target.getHealth() + target.getAbsorptionAmount();
|
||||
if (origin == null || origin.session() == null || !sources.current(origin)
|
||||
|| targetId.equals(origin.player()) || !Double.isFinite(remaining)
|
||||
|| event.getFinalDamage() < remaining) {
|
||||
lethal.remove(targetId); return;
|
||||
}
|
||||
lethal.put(targetId, new Candidate(target, origin));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
||||
public void beforePlayerDeath(PlayerDeathEvent event) {
|
||||
if (!closed && sessions.active(event.getEntity().getUniqueId()).isPresent()) fantasyDefeats.add(event);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void playerDeath(PlayerDeathEvent event) {
|
||||
boolean fantasy = fantasyDefeats.remove(event);
|
||||
if (!closed && fantasy && event.isCancelled()) defeat(event, event.getEntity());
|
||||
else lethal.remove(event.getEntity().getUniqueId());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void mobDeath(EntityDeathEvent event) {
|
||||
if (!closed && !(event instanceof PlayerDeathEvent)) defeat(event, event.getEntity());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void removed(EntityRemoveEvent event) {
|
||||
if (event.getCause() != EntityRemoveEvent.Cause.DEATH) lethal.remove(event.getEntity().getUniqueId());
|
||||
}
|
||||
|
||||
private void defeat(EntityDeathEvent event, LivingEntity target) {
|
||||
if (!consumed.add(event)) return;
|
||||
consumedOrder.addLast(event);
|
||||
while (consumedOrder.size() > MAX_CONSUMED_EVENTS) consumed.remove(consumedOrder.removeFirst());
|
||||
var candidate = lethal.remove(target.getUniqueId());
|
||||
if (candidate == null || candidate.target() != target || !sources.current(candidate.origin())) return;
|
||||
var origin = candidate.origin();
|
||||
var active = sessions.active(origin.player()).orElse(null);
|
||||
if (active == null || !active.session().equals(origin.session()) || !active.character().equals(origin.character())
|
||||
|| !sessions.current(active)) return;
|
||||
var record = sessions.currentRecord(active).orElse(null);
|
||||
var player = sessions.currentPlayer(active).orElse(null);
|
||||
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) return;
|
||||
BigInteger configured = settings.healing();
|
||||
if (configured.signum() == 0) return;
|
||||
var maximumAttribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (maximumAttribute == null) return;
|
||||
double current = player.getHealth();
|
||||
double maximum = Math.min(maximumAttribute.getValue(), record.maximumHealth().doubleValue());
|
||||
double reserved = pending.getOrDefault(active.character(), new ArrayDeque<>()).stream()
|
||||
.mapToDouble(Heal::amount).sum();
|
||||
double amount = configured.bitLength() > 1023 ? maximum - current - reserved : configured.doubleValue();
|
||||
double proposed = Math.min(maximum - current - reserved, amount);
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || current < 0 || maximum <= 0
|
||||
|| !Double.isFinite(proposed) || proposed <= 0) return;
|
||||
var healing = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(healing);
|
||||
double accepted = Math.min(proposed, Math.min(maximum - current - reserved, healing.getAmount()));
|
||||
if (healing.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
|
||||
var queue = pending.computeIfAbsent(active.character(), ignored -> new ArrayDeque<>());
|
||||
if (queue.size() >= MAX_PENDING_PER_CHARACTER) return;
|
||||
queue.addLast(new Heal(active, accepted));
|
||||
if (queue.size() == 1) attempt(active.character());
|
||||
}
|
||||
|
||||
private void attempt(UUID character) {
|
||||
var queue = pending.get(character); var operation = queue == null ? null : queue.peekFirst();
|
||||
if (closed || operation == null) return;
|
||||
if (!sessions.current(operation.active())) { finish(character); return; }
|
||||
var record = sessions.currentRecord(operation.active()).orElse(null);
|
||||
var player = sessions.currentPlayer(operation.active()).orElse(null);
|
||||
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) { finish(character); return; }
|
||||
double nativeBefore = player.getHealth();
|
||||
BigDecimal target = record.health().add(BigDecimal.valueOf(operation.amount()));
|
||||
BigDecimal limit;
|
||||
try { limit = nativeLimit.get(); }
|
||||
catch (RuntimeException failure) { report(character, failure); finish(character); return; }
|
||||
BigInteger expected = record.revision();
|
||||
characters.saveHealth(character, expected, target, limit).whenComplete((saved, failure) -> {
|
||||
try { nextTick.accept(() -> complete(character, operation, expected, nativeBefore, saved, failure)); }
|
||||
catch (RuntimeException rejected) { report(character, rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void complete(UUID character, Heal operation, BigInteger expected, double nativeBefore,
|
||||
java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
try {
|
||||
if (closed || failure != null || saved == null || saved.isEmpty()) {
|
||||
if (failure != null) report(character, failure); return;
|
||||
}
|
||||
var record = saved.orElseThrow();
|
||||
if (!sessions.current(operation.active())
|
||||
|| !sessions.acknowledgeProgress(operation.active(), expected, record)) return;
|
||||
var player = sessions.currentPlayer(operation.active()).orElse(null);
|
||||
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) return;
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) return;
|
||||
double maximum = Math.min(attribute.getValue(), record.maximumHealth().doubleValue());
|
||||
double publish = Math.min(maximum, record.health().doubleValue());
|
||||
if (Double.isFinite(maximum) && Double.isFinite(publish) && publish > nativeBefore) player.setHealth(publish);
|
||||
} finally { finish(character); }
|
||||
}
|
||||
|
||||
private void finish(UUID character) {
|
||||
var queue = pending.get(character);
|
||||
if (queue == null) return;
|
||||
queue.pollFirst();
|
||||
if (queue.isEmpty()) pending.remove(character); else attempt(character);
|
||||
}
|
||||
private void report(UUID character, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, failure)); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
@Override public void close() {
|
||||
closed = true; lethal.clear(); fantasyDefeats.clear(); consumed.clear(); consumedOrder.clear(); pending.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Current-session native Night Vision I layer for the learned character passive. */
|
||||
public final class LowLightVision implements AutoCloseable {
|
||||
public static final String ID = "low_light_vision";
|
||||
private final PersistentPotionEffect effect;
|
||||
|
||||
public LowLightVision(EquipmentSessions sessions) {
|
||||
effect = new PersistentPotionEffect(ID, PotionEffectType.NIGHT_VISION, 0, false, sessions);
|
||||
}
|
||||
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Low-Light Vision", Map.of(Stat.WIS, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE,
|
||||
List.of("Continuously provides native Night Vision I while this character is active.",
|
||||
"Preserves unrelated Night Vision and does not change light, darkness or mob behavior."));
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
|
||||
public void sweep() { effect.sweep(); }
|
||||
@Override public void close() { effect.close(); }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Immutable internal metadata for one character-owned automatic passive. */
|
||||
public record PassiveDefinition(String id, String name, Map<Stat, BigInteger> requirements,
|
||||
Set<String> prerequisites, BigInteger pointCost, List<String> description) {
|
||||
public PassiveDefinition {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(requirements);
|
||||
Objects.requireNonNull(prerequisites); Objects.requireNonNull(pointCost); Objects.requireNonNull(description);
|
||||
requirements = Map.copyOf(requirements); prerequisites = Set.copyOf(prerequisites); description = List.copyOf(description);
|
||||
if (!id.matches("[a-z][a-z0-9_]{0,63}") || name.isBlank() || !pointCost.equals(BigInteger.ONE)
|
||||
|| requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)
|
||||
|| prerequisites.stream().anyMatch(value -> value.isBlank() || value.equals(id))
|
||||
|| description.isEmpty() || description.stream().anyMatch(String::isBlank)) {
|
||||
throw new IllegalArgumentException("Invalid passive definition");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
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.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Save-before-publication passive learning/refund coordinator for native book actions. */
|
||||
public final class PassiveEngine implements AutoCloseable {
|
||||
public record Failure(UUID character, String passive, Throwable cause) { }
|
||||
public record Mutation(CharacterStorage.PassiveMutationStatus status, CharacterRecord record) { }
|
||||
private final PassiveRegistry registry;
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> failures;
|
||||
private boolean closed;
|
||||
|
||||
public PassiveEngine(PassiveRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures) {
|
||||
this.registry = Objects.requireNonNull(registry); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public CompletableFuture<Mutation> purchase(ControlInput input, String passive, BigInteger expectedRevision) {
|
||||
var definition = registry.find(passive).orElse(null);
|
||||
if (closed || definition == null || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
|
||||
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.PassiveMutationStatus.UNAVAILABLE, null));
|
||||
}
|
||||
return mutate(input, passive, expectedRevision, () -> characters.unlockPassive(input.session().character(),
|
||||
expectedRevision, passive, definition.requirements(), definition.prerequisites(), definition.pointCost()));
|
||||
}
|
||||
|
||||
public CompletableFuture<Mutation> unlearn(ControlInput input, String passive, BigInteger expectedRevision) {
|
||||
if (closed || registry.find(passive).isEmpty() || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
|
||||
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.PassiveMutationStatus.UNAVAILABLE, null));
|
||||
}
|
||||
return mutate(input, passive, expectedRevision, () -> characters.unlearnPassive(input.session().character(),
|
||||
expectedRevision, passive, registry.dependents(passive)));
|
||||
}
|
||||
|
||||
private CompletableFuture<Mutation> mutate(ControlInput input, String passive, BigInteger expectedRevision,
|
||||
java.util.function.Supplier<CompletableFuture<CharacterStorage.PassiveMutation>> operation) {
|
||||
var result = new CompletableFuture<Mutation>();
|
||||
CompletableFuture<CharacterStorage.PassiveMutation> work;
|
||||
try { work = operation.get(); }
|
||||
catch (RuntimeException failure) { report(input.session().character(), passive, failure); result.completeExceptionally(failure); return result; }
|
||||
work.whenComplete((mutation, failure) -> {
|
||||
try { nextTick.accept(() -> {
|
||||
if (failure != null) { report(input.session().character(), passive, failure); result.completeExceptionally(failure); return; }
|
||||
var record = mutation.record().orElse(null);
|
||||
if (mutation.status() == CharacterStorage.PassiveMutationStatus.APPLIED
|
||||
&& (record == null || !input.stillCurrent()
|
||||
|| !sessions.acknowledgeProgress(input.session(), expectedRevision, record))) {
|
||||
result.complete(new Mutation(CharacterStorage.PassiveMutationStatus.STALE, record)); return;
|
||||
}
|
||||
result.complete(new Mutation(mutation.status(), record));
|
||||
}); } catch (RuntimeException rejected) {
|
||||
report(input.session().character(), passive, rejected); result.completeExceptionally(rejected);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private void report(UUID character, String passive, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, passive, failure)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
@Override public void close() { closed = true; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Internal stable-ID passive catalogue consumed by persistence, books and focused runtime services. */
|
||||
public final class PassiveRegistry {
|
||||
private final Map<String, PassiveDefinition> definitions = new LinkedHashMap<>();
|
||||
|
||||
public synchronized void register(PassiveDefinition definition) {
|
||||
Objects.requireNonNull(definition);
|
||||
if (definitions.containsKey(definition.id())) {
|
||||
throw new IllegalArgumentException("Duplicate passive ID: " + definition.id());
|
||||
}
|
||||
validatePrerequisites(definition);
|
||||
definitions.put(definition.id(), definition);
|
||||
}
|
||||
|
||||
public synchronized void replace(PassiveDefinition definition) {
|
||||
Objects.requireNonNull(definition);
|
||||
if (!definitions.containsKey(definition.id())) throw new IllegalArgumentException("Passive is not registered");
|
||||
validatePrerequisites(definition);
|
||||
definitions.put(definition.id(), definition);
|
||||
}
|
||||
|
||||
private void validatePrerequisites(PassiveDefinition definition) {
|
||||
if (!definitions.keySet().containsAll(definition.prerequisites())) {
|
||||
throw new IllegalArgumentException("Passive prerequisite is not registered");
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized Optional<PassiveDefinition> find(String id) {
|
||||
return Optional.ofNullable(definitions.get(id));
|
||||
}
|
||||
public synchronized Collection<PassiveDefinition> definitions() { return java.util.List.copyOf(definitions.values()); }
|
||||
public synchronized java.util.Set<String> dependents(String id) {
|
||||
return definitions.values().stream().filter(definition -> definition.prerequisites().contains(id))
|
||||
.map(PassiveDefinition::id).collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Bounded current-character ownership for one persistent native potion layer. */
|
||||
final class PersistentPotionEffect implements AutoCloseable {
|
||||
private static final int MAX_ACTIVE_CHECKS = 256;
|
||||
|
||||
private final class State {
|
||||
final EquipmentSessions.Active active;
|
||||
final Player player;
|
||||
PotionEffect owned;
|
||||
PotionEffect displaced;
|
||||
long displacedUntil;
|
||||
long refreshedAt;
|
||||
State(EquipmentSessions.Active active, Player player) { this.active = active; this.player = player; }
|
||||
|
||||
void refresh() {
|
||||
refreshedAt = sweepGeneration;
|
||||
if (displaced != null && !displaced.isInfinite() && sweepGeneration >= displacedUntil) displaced = null;
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(type); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (owned != null && !same(current, owned)) { owned = null; displaced = null; }
|
||||
if (!eligible()) { remove(); return; }
|
||||
if (current != null && (!replaceWeaker || current.getAmplifier() >= amplifier)) return;
|
||||
var effect = new PotionEffect(type, PotionEffect.INFINITE_DURATION, amplifier, false, false, true);
|
||||
try {
|
||||
if (player.addPotionEffect(effect, true)) {
|
||||
owned = effect; displaced = current;
|
||||
displacedUntil = current == null ? 0 : current.isInfinite()
|
||||
? Long.MAX_VALUE : sweepGeneration + current.getDuration();
|
||||
}
|
||||
} catch (RuntimeException unavailable) { owned = null; displaced = null; }
|
||||
}
|
||||
|
||||
boolean eligible() {
|
||||
if (closed || !sessions.current(active)) return false;
|
||||
var record = sessions.currentRecord(active).orElse(null);
|
||||
return record != null && !record.dead() && record.passives().contains(passive);
|
||||
}
|
||||
|
||||
void remove() {
|
||||
if (owned != null) {
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(type); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (same(current, owned)) {
|
||||
try {
|
||||
player.removePotionEffect(type);
|
||||
if (displaced != null && (displaced.isInfinite() || sweepGeneration < displacedUntil)) {
|
||||
int duration = displaced.isInfinite() ? PotionEffect.INFINITE_DURATION
|
||||
: Math.toIntExact(Math.min(Integer.MAX_VALUE, displacedUntil - sweepGeneration));
|
||||
player.addPotionEffect(new PotionEffect(displaced.getType(), duration, displaced.getAmplifier(),
|
||||
displaced.isAmbient(), displaced.hasParticles(), displaced.hasIcon()), true);
|
||||
}
|
||||
} catch (RuntimeException unavailable) { return; }
|
||||
}
|
||||
owned = null; displaced = null;
|
||||
}
|
||||
states.remove(active.player(), this);
|
||||
}
|
||||
}
|
||||
|
||||
private final String passive;
|
||||
private final PotionEffectType type;
|
||||
private final int amplifier;
|
||||
private final boolean replaceWeaker;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Map<UUID, State> states = new java.util.HashMap<>();
|
||||
private int activeCursor;
|
||||
private int stateCursor;
|
||||
private long sweepGeneration;
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
PersistentPotionEffect(String passive, PotionEffectType type, int amplifier, boolean replaceWeaker,
|
||||
EquipmentSessions sessions) {
|
||||
this.passive = Objects.requireNonNull(passive); this.type = Objects.requireNonNull(type);
|
||||
if (passive.isBlank() || amplifier < 0) throw new IllegalArgumentException("Invalid persistent potion effect");
|
||||
this.amplifier = amplifier; this.replaceWeaker = replaceWeaker; this.sessions = Objects.requireNonNull(sessions);
|
||||
}
|
||||
|
||||
void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin);
|
||||
if (closed || sweepTask != null) throw new IllegalStateException("Passive potion sweep already installed or closed");
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
|
||||
void sweep() {
|
||||
if (closed) return;
|
||||
sweepGeneration++;
|
||||
var active = sessions.activeCharacters();
|
||||
int activeChecks = Math.min(MAX_ACTIVE_CHECKS, active.size());
|
||||
for (int index = 0; index < activeChecks; index++) {
|
||||
var snapshot = active.get((activeCursor + index) % active.size());
|
||||
reconcile(snapshot);
|
||||
}
|
||||
activeCursor = active.isEmpty() ? 0 : (activeCursor + activeChecks) % active.size();
|
||||
|
||||
var known = List.copyOf(states.values());
|
||||
int stateChecks = Math.min(MAX_ACTIVE_CHECKS, known.size());
|
||||
for (int index = 0; index < stateChecks; index++) {
|
||||
var state = known.get((stateCursor + index) % known.size());
|
||||
if (state.refreshedAt != sweepGeneration) state.refresh();
|
||||
}
|
||||
stateCursor = known.isEmpty() ? 0 : (stateCursor + stateChecks) % known.size();
|
||||
}
|
||||
|
||||
private void reconcile(EquipmentSessions.ActiveCharacter snapshot) {
|
||||
var active = snapshot.session();
|
||||
var state = states.get(active.player());
|
||||
boolean learned = !snapshot.record().dead() && snapshot.record().passives().contains(passive);
|
||||
if (!learned || !sessions.current(active)) { if (state != null) state.refresh(); return; }
|
||||
if (state != null && !state.active.equals(active)) { state.remove(); state = states.get(active.player()); }
|
||||
if (state == null) {
|
||||
var player = sessions.currentPlayer(active).orElse(null);
|
||||
if (player == null) return;
|
||||
state = new State(active, player); states.put(active.player(), state);
|
||||
}
|
||||
state.refresh();
|
||||
}
|
||||
|
||||
private static boolean same(PotionEffect first, PotionEffect second) {
|
||||
return first != null && second != null && first.getType().equals(second.getType())
|
||||
&& first.getAmplifier() == second.getAmplifier() && first.isInfinite() && second.isInfinite()
|
||||
&& first.isAmbient() == second.isAmbient() && first.hasParticles() == second.hasParticles()
|
||||
&& first.hasIcon() == second.hasIcon();
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (sweepTask != null) sweepTask.cancel();
|
||||
for (var state : List.copyOf(states.values())) state.remove();
|
||||
states.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Quicker Regeneration I metadata and live configured pulse bonus. */
|
||||
public final class QuickerRegenerationI {
|
||||
public static final String ID = "quicker_regeneration_i";
|
||||
public record Settings(BigInteger bonus) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(bonus);
|
||||
if (bonus.signum() < 0 || bonus.compareTo(BigInteger.valueOf(1_024)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Quicker Regeneration I bonus");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.ONE); }
|
||||
}
|
||||
private volatile Settings settings;
|
||||
public QuickerRegenerationI(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
|
||||
public BigInteger bonus() { return settings.bonus(); }
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Quicker Regeneration I",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE,
|
||||
List.of("Add " + bonus() + " health point" + (bonus().equals(BigInteger.ONE) ? "" : "s")
|
||||
+ " to each eligible out-of-combat recovery pulse.",
|
||||
"Uses the same capped persisted custom-healing transaction as base recovery."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Quicker Regeneration II metadata and live configured pulse bonus. */
|
||||
public final class QuickerRegenerationII {
|
||||
public static final String ID = "quicker_regeneration_ii";
|
||||
public record Settings(BigInteger bonus) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(bonus);
|
||||
if (bonus.signum() < 0 || bonus.compareTo(BigInteger.valueOf(1_024)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Quicker Regeneration II bonus");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.TWO); }
|
||||
}
|
||||
private volatile Settings settings;
|
||||
public QuickerRegenerationII(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
|
||||
public BigInteger bonus() { return settings.bonus(); }
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Quicker Regeneration II",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.valueOf(14)),
|
||||
Set.of(QuickerRegenerationI.ID), BigInteger.ONE,
|
||||
List.of("Add " + bonus() + " health point" + (bonus().equals(BigInteger.ONE) ? "" : "s")
|
||||
+ " beyond Quicker Regeneration I to each eligible out-of-combat recovery pulse.",
|
||||
"Both bonuses use the same capped persisted custom-healing transaction as base recovery."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Current-session native Speed I layer for the learned character passive. */
|
||||
public final class SpeedI implements AutoCloseable {
|
||||
public static final String ID = "speed_i";
|
||||
private final PersistentPotionEffect effect;
|
||||
|
||||
public SpeedI(EquipmentSessions sessions) {
|
||||
effect = new PersistentPotionEffect(ID, PotionEffectType.SPEED, 0, true, sessions);
|
||||
}
|
||||
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Speed I", Map.of(Stat.DEX, BigInteger.valueOf(14)), Set.of(), BigInteger.ONE,
|
||||
List.of("Continuously provides the standard native Speed I effect while this character is active.",
|
||||
"Does not stack with equal Speed I and preserves stronger unrelated Speed effects."));
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
|
||||
public void sweep() { effect.sweep(); }
|
||||
@Override public void close() { effect.close(); }
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.ChainLightning;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
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 Chain Lightning settings; publication remains a server-thread caller decision. */
|
||||
public final class ChainLightningSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE = "chain-lightning-base-damage";
|
||||
public static final String COOLDOWN = "chain-lightning-cooldown-seconds";
|
||||
public static final String INITIAL_RANGE = "chain-lightning-initial-range-blocks";
|
||||
public static final String JUMP_RANGE = "chain-lightning-jump-range-blocks";
|
||||
public static final String ADDITIONAL = "chain-lightning-additional-targets";
|
||||
public static final String MULTIPLIER = "chain-lightning-jump-multiplier";
|
||||
private static final List<String> KEYS = List.of(BASE, COOLDOWN, INITIAL_RANGE, JUMP_RANGE, ADDITIONAL, MULTIPLIER);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
BASE, "8", COOLDOWN, "18", INITIAL_RANGE, "16", JUMP_RANGE, "6", ADDITIONAL, "3", MULTIPLIER, "0.75");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, ChainLightning.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-chain-lightning-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 ChainLightningSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
ChainLightningSettings(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 ChainLightning.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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(value); var preview = new LinkedHashMap<>(effectiveValues);
|
||||
preview.put(key, accepted); parse(preview);
|
||||
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("Chain Lightning 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 raw) {
|
||||
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static ChainLightning.Settings parse(Map<String, String> values) {
|
||||
try { return new ChainLightning.Settings(new BigDecimal(values.get(BASE)).toBigIntegerExact(),
|
||||
seconds(values.get(COOLDOWN)), finite(values.get(INITIAL_RANGE)), finite(values.get(JUMP_RANGE)),
|
||||
new BigDecimal(values.get(ADDITIONAL)).intValueExact(), new BigDecimal(values.get(MULTIPLIER))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Chain Lightning settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new 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); }
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import java.util.UUID;
|
||||
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,
|
||||
Set<String> learned, List<String> loadout, List<String> passives, 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,
|
||||
@@ -24,7 +24,16 @@ public record CharacterRecord(
|
||||
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);
|
||||
List.of("", "", ""), cooldowns, Map.of(), dead, deathId, 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,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout,
|
||||
List.of("", "", ""), cooldowns, restrictedKills, dead, deathId, diedAt);
|
||||
}
|
||||
|
||||
public CharacterRecord {
|
||||
@@ -37,6 +46,7 @@ public record CharacterRecord(
|
||||
stats = Map.copyOf(stats);
|
||||
learned = Set.copyOf(learned);
|
||||
loadout = List.copyOf(loadout);
|
||||
passives = List.copyOf(passives);
|
||||
cooldowns = Map.copyOf(cooldowns);
|
||||
restrictedKills = Map.copyOf(restrictedKills);
|
||||
if (revision.signum() < 0 || experience.signum() < 0 || unspentPoints.signum() < 0 || level < 1
|
||||
@@ -44,7 +54,11 @@ public record CharacterRecord(
|
||||
|| stats.values().stream().anyMatch(value -> value.signum() < 0)
|
||||
|| health.signum() < 0
|
||||
|| health.compareTo(new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN))) > 0
|
||||
|| loadout.size() != 7 || learned.stream().anyMatch(String::isBlank)
|
||||
|| loadout.size() != 7 || passives.size() != 3
|
||||
|| passives.stream().filter(passiveId -> !passiveId.isEmpty()).anyMatch(String::isBlank)
|
||||
|| passives.stream().filter(passiveId -> !passiveId.isEmpty()).distinct().count()
|
||||
!= passives.stream().filter(passiveId -> !passiveId.isEmpty()).count()
|
||||
|| 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()))
|
||||
|
||||
@@ -53,7 +53,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
|| record.dead() || record.deathId() != null || record.level() != 1
|
||||
|| record.experience().signum() != 0 || record.stats().values().stream().anyMatch(value -> value.signum() != 0)
|
||||
|| record.health().compareTo(java.math.BigDecimal.TEN) != 0
|
||||
|| !record.learned().isEmpty() || !record.cooldowns().isEmpty()
|
||||
|| !record.learned().isEmpty() || record.passives().stream().anyMatch(id -> !id.isEmpty())
|
||||
|| !record.cooldowns().isEmpty()
|
||||
|| record.loadout().stream().anyMatch(ability -> !ability.isEmpty())) {
|
||||
throw new IllegalArgumentException("Craft batch requires distinct new characters");
|
||||
}
|
||||
@@ -205,7 +206,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var old = found.orElseThrow();
|
||||
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(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(renamed, old.revision());
|
||||
return Optional.of(renamed);
|
||||
@@ -246,7 +247,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
int gained = Math.max(0, level - old.level());
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
|
||||
});
|
||||
@@ -288,14 +289,82 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
}
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), history, false, old.deathId(), old.diedAt());
|
||||
old.loadout(), old.passives(), old.cooldowns(), history, false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new CombatAward(status, Optional.of(saved), gained);
|
||||
});
|
||||
}
|
||||
|
||||
public enum PassiveMutationStatus {
|
||||
APPLIED, STALE, REQUIREMENTS, NO_POINTS, PASSIVE_SLOTS_FULL, ALREADY_LEARNED,
|
||||
NOT_LEARNED, REQUIRED_BY_LEARNED, UNAVAILABLE
|
||||
}
|
||||
public record PassiveMutation(PassiveMutationStatus status, Optional<CharacterRecord> record) { }
|
||||
|
||||
/** Learn one registered passive in the lowest empty independent slot as one character revision. */
|
||||
public CompletableFuture<PassiveMutation> unlockPassive(UUID characterId, BigInteger expectedRevision, String passive,
|
||||
java.util.Map<Stat, BigInteger> requirements, Set<String> prerequisites, BigInteger cost) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(passive);
|
||||
Objects.requireNonNull(requirements); Objects.requireNonNull(prerequisites); Objects.requireNonNull(cost);
|
||||
if (!passive.matches("[a-z][a-z0-9_]{0,63}") || !cost.equals(BigInteger.ONE)
|
||||
|| requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)
|
||||
|| prerequisites.stream().anyMatch(String::isBlank)) {
|
||||
throw new IllegalArgumentException("Invalid passive unlock");
|
||||
}
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new PassiveMutation(PassiveMutationStatus.UNAVAILABLE, Optional.empty());
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) return new PassiveMutation(PassiveMutationStatus.STALE, Optional.of(old));
|
||||
if (old.passives().contains(passive)) return new PassiveMutation(PassiveMutationStatus.ALREADY_LEARNED, Optional.of(old));
|
||||
if (!old.passives().containsAll(prerequisites) || requirements.entrySet().stream()
|
||||
.anyMatch(entry -> old.stats().get(entry.getKey()).compareTo(entry.getValue()) < 0)) {
|
||||
return new PassiveMutation(PassiveMutationStatus.REQUIREMENTS, Optional.of(old));
|
||||
}
|
||||
if (old.unspentPoints().compareTo(cost) < 0) return new PassiveMutation(PassiveMutationStatus.NO_POINTS, Optional.of(old));
|
||||
int slot = old.passives().indexOf("");
|
||||
if (slot < 0) return new PassiveMutation(PassiveMutationStatus.PASSIVE_SLOTS_FULL, Optional.of(old));
|
||||
var passives = new java.util.ArrayList<>(old.passives()); passives.set(slot, passive);
|
||||
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(), old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
/** Refund one passive once while refusing removal beneath a registered learned dependent. */
|
||||
public CompletableFuture<PassiveMutation> unlearnPassive(UUID characterId, BigInteger expectedRevision,
|
||||
String passive, Set<String> learnedDependents) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(passive);
|
||||
Objects.requireNonNull(learnedDependents);
|
||||
if (!passive.matches("[a-z][a-z0-9_]{0,63}") || learnedDependents.stream().anyMatch(String::isBlank)) {
|
||||
throw new IllegalArgumentException("Invalid passive refund");
|
||||
}
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new PassiveMutation(PassiveMutationStatus.UNAVAILABLE, Optional.empty());
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) return new PassiveMutation(PassiveMutationStatus.STALE, Optional.of(old));
|
||||
if (!old.passives().contains(passive)) return new PassiveMutation(PassiveMutationStatus.NOT_LEARNED, Optional.of(old));
|
||||
if (old.passives().stream().anyMatch(learnedDependents::contains)) {
|
||||
return new PassiveMutation(PassiveMutationStatus.REQUIRED_BY_LEARNED, Optional.of(old));
|
||||
}
|
||||
var passives = old.passives().stream().map(id -> id.equals(passive) ? "" : id).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(), old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
public enum AbilityMutationStatus {
|
||||
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
|
||||
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, LOADOUT_FULL, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
|
||||
}
|
||||
public record AbilityMutation(AbilityMutationStatus status, Optional<CharacterRecord> record) { }
|
||||
|
||||
@@ -317,9 +386,12 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
return new AbilityMutation(AbilityMutationStatus.REQUIREMENTS, Optional.of(old));
|
||||
}
|
||||
if (old.unspentPoints().compareTo(cost) < 0) return new AbilityMutation(AbilityMutationStatus.NO_POINTS, Optional.of(old));
|
||||
int slot = old.loadout().indexOf("");
|
||||
if (slot < 0) return new AbilityMutation(AbilityMutationStatus.LOADOUT_FULL, Optional.of(old));
|
||||
var learned = new java.util.HashSet<>(old.learned()); learned.add(ability);
|
||||
var loadout = new java.util.ArrayList<>(old.loadout()); loadout.set(slot, 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.experience(), old.unspentPoints().subtract(cost), old.health(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
@@ -339,7 +411,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
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.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
@@ -362,7 +434,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
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.experience(), old.unspentPoints(), old.health(), old.learned(), loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
@@ -386,7 +458,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
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.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), cooldowns,
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
@@ -431,7 +503,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var gain = rawMaximum.min(nativeLimit).subtract(oldMaximum);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
|
||||
currentHealth.add(gain), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false,
|
||||
currentHealth.add(gain), old.learned(), old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false,
|
||||
old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
|
||||
@@ -457,7 +529,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
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(),
|
||||
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return Optional.of(saved);
|
||||
@@ -489,7 +561,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (old.dead() || expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
|
||||
var dead = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.ZERO, old.learned(),
|
||||
old.loadout(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt);
|
||||
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt);
|
||||
repository.replace(dead, old.revision());
|
||||
return Optional.of(dead);
|
||||
});
|
||||
@@ -525,7 +597,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
|| ageLimited && 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(), health, old.learned(), old.loadout(), old.cooldowns(),
|
||||
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(restored, old.revision());
|
||||
return Optional.of(restored);
|
||||
|
||||
@@ -42,6 +42,7 @@ final class CharacterYaml {
|
||||
root.put("health", record.health().toString());
|
||||
root.put("learned-abilities", record.learned().stream().sorted().toList());
|
||||
root.put("loadout", record.loadout());
|
||||
root.put("learned-passives", record.passives());
|
||||
List<Map<String, String>> cooldowns = new ArrayList<>();
|
||||
record.cooldowns().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry ->
|
||||
cooldowns.add(Map.of("ability", entry.getKey(), "ready-at", entry.getValue().toString())));
|
||||
@@ -93,8 +94,9 @@ final class CharacterYaml {
|
||||
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, restrictedKills,
|
||||
dead, root.containsKey("death-id") ? UUID.fromString(text(root.get("death-id"))) : null,
|
||||
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")),
|
||||
root.containsKey("learned-passives") ? strings(root.get("learned-passives")) : List.of("", "", ""),
|
||||
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,172 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.FrostNova;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
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 Nova settings; publication remains a server-thread caller decision. */
|
||||
public final class FrostNovaSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE = "frost-nova-base-damage";
|
||||
public static final String COOLDOWN = "frost-nova-cooldown-seconds";
|
||||
public static final String RADIUS = "frost-nova-radius-blocks";
|
||||
public static final String SLOWNESS = "frost-nova-slowness-amplifier";
|
||||
public static final String DURATION = "frost-nova-slowness-duration-seconds";
|
||||
private static final List<String> KEYS = List.of(BASE, COOLDOWN, RADIUS, SLOWNESS, DURATION);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
BASE, "5", COOLDOWN, "12", RADIUS, "5", SLOWNESS, "1", DURATION, "3");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, FrostNova.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-nova-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 FrostNovaSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
FrostNovaSettings(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 FrostNova.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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(value); var preview = new LinkedHashMap<>(effectiveValues);
|
||||
preview.put(key, accepted); parse(preview);
|
||||
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 Nova 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 raw) {
|
||||
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static FrostNova.Settings parse(Map<String, String> values) {
|
||||
try { return new FrostNova.Settings(new BigDecimal(values.get(BASE)).toBigIntegerExact(),
|
||||
seconds(values.get(COOLDOWN)), finite(values.get(RADIUS)),
|
||||
new BigDecimal(values.get(SLOWNESS)).intValueExact(), seconds(values.get(DURATION))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Frost Nova settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new 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,170 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.GuardianBond;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
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 Guardian Bond settings; publication remains a server-thread caller decision. */
|
||||
public final class GuardianBondSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "guardian-bond-cooldown-seconds";
|
||||
public static final String RANGE = "guardian-bond-range-blocks";
|
||||
public static final String DURATION = "guardian-bond-duration-seconds";
|
||||
public static final String REDIRECT = "guardian-bond-redirect-percent";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, DURATION, RANGE, REDIRECT);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
COOLDOWN, "20", RANGE, "10", DURATION, "6", REDIRECT, "30");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, GuardianBond.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-guardian-bond-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 GuardianBondSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
GuardianBondSettings(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 GuardianBond.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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(value); var preview = new LinkedHashMap<>(effectiveValues);
|
||||
preview.put(key, accepted); parse(preview);
|
||||
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("Guardian Bond 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 raw) {
|
||||
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static GuardianBond.Settings parse(Map<String, String> values) {
|
||||
try { return new GuardianBond.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RANGE)),
|
||||
seconds(values.get(DURATION)), new BigDecimal(values.get(REDIRECT))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Guardian Bond settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new 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,113 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.passive.HealthSteal;
|
||||
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.util.LinkedHashMap;
|
||||
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;
|
||||
|
||||
/** Durable save-before-publication Health Steal balance. */
|
||||
public final class HealthStealSettings implements AutoCloseable {
|
||||
public static final String KEY = "health-steal-healing-per-kill";
|
||||
public static final String DEFAULT = "1";
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String value, HealthSteal.Settings settings, long sequence) { }
|
||||
private final Path directory; private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-health-steal-configuration"));
|
||||
private volatile String effective; private String persisted; private long persistedSequence, publishedSequence;
|
||||
private boolean closed; private int pending; private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public HealthStealSettings(Path directory, String initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
HealthStealSettings(Path directory, String initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
|
||||
effective = canonical(initial); persisted = effective;
|
||||
}
|
||||
public static HealthSteal.Settings parse(String raw) {
|
||||
try { return new HealthSteal.Settings(new java.math.BigInteger(raw)); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Bonus must be a whole number", invalid); }
|
||||
}
|
||||
private static String canonical(String raw) { var parsed = parse(raw); return parsed.healing().toString(); }
|
||||
public String value() { return effective; }
|
||||
public HealthSteal.Settings settings() { return parse(effective); }
|
||||
public CompletionStage<Update> set(String raw) {
|
||||
String accepted = canonical(raw);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed")); pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try { save(accepted); persisted = accepted; return new Update(accepted, parse(accepted), ++persistedSequence); }
|
||||
catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Health Steal configuration save failed; effective value unchanged", failure));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
|
||||
}
|
||||
}
|
||||
public synchronized boolean publish(Update update) {
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
effective = canonical(update.value()); publishedSequence = update.sequence(); return true;
|
||||
}
|
||||
private void save(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 key)) throw new IOException("Invalid configuration key");
|
||||
document.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(KEY, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
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", 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,172 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.MendingLight;
|
||||
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.math.BigDecimal;
|
||||
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 Mending Light settings; publication remains a server-thread caller decision. */
|
||||
public final class MendingLightSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_HEALING = "mending-light-base-healing";
|
||||
public static final String COOLDOWN = "mending-light-cooldown-seconds";
|
||||
public static final String RANGE = "mending-light-range-blocks";
|
||||
private static final List<String> KEYS = List.of(BASE_HEALING, COOLDOWN, RANGE);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_HEALING, "6", COOLDOWN, "8", RANGE, "12");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, MendingLight.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-mending-light-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 MendingLightSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
MendingLightSettings(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 MendingLight.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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);
|
||||
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("Mending Light 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 BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static MendingLight.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
return new MendingLight.Settings(new java.math.BigInteger(values.get(BASE_HEALING)),
|
||||
seconds(values.get(COOLDOWN)), finite(values.get(RANGE)));
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Mending Light settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new 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,176 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Meteor;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
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 Meteor settings; publication remains a server-thread caller decision. */
|
||||
public final class MeteorSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_DAMAGE = "meteor-base-damage";
|
||||
public static final String COOLDOWN = "meteor-cooldown-seconds";
|
||||
public static final String TARGET_RANGE = "meteor-target-range-blocks";
|
||||
public static final String WARNING = "meteor-warning-seconds";
|
||||
public static final String RADIUS = "meteor-radius-blocks";
|
||||
public static final String VERTICAL = "meteor-vertical-range-blocks";
|
||||
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, TARGET_RANGE, WARNING, RADIUS, VERTICAL);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
BASE_DAMAGE, "14", COOLDOWN, "45", TARGET_RANGE, "24", WARNING, "2", RADIUS, "5", VERTICAL, "4");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Meteor.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-meteor-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 MeteorSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
MeteorSettings(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 Meteor.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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(value); var preview = new LinkedHashMap<>(effectiveValues);
|
||||
preview.put(key, accepted); parse(preview);
|
||||
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("Meteor 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 raw) {
|
||||
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static Meteor.Settings parse(Map<String, String> values) {
|
||||
try { return new Meteor.Settings(new BigDecimal(values.get(BASE_DAMAGE)).toBigIntegerExact(),
|
||||
seconds(values.get(COOLDOWN)), finite(values.get(TARGET_RANGE)), seconds(values.get(WARNING)),
|
||||
finite(values.get(RADIUS)), finiteNonnegative(values.get(VERTICAL))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Meteor settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
private static double finiteNonnegative(String value) {
|
||||
double result = finite(value); if (result < 0) throw new IllegalArgumentException("Setting is negative"); 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,184 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.session.OutOfCombatRecovery;
|
||||
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;
|
||||
|
||||
/** Durable save-before-publication settings for active-session out-of-combat recovery. */
|
||||
public final class OutOfCombatRecoverySettings implements AutoCloseable {
|
||||
public static final String DELAY = "out-of-combat-healing-delay-seconds";
|
||||
public static final String INTERVAL = "out-of-combat-healing-interval-seconds";
|
||||
public static final String AMOUNT = "out-of-combat-healing-amount";
|
||||
private static final List<String> KEYS = List.of(DELAY, INTERVAL, AMOUNT);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(DELAY, "30", INTERVAL, "10", AMOUNT, "1");
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
private static final long MAX_SECONDS = 31_536_000L;
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, OutOfCombatRecovery.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-out-of-combat-recovery-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 OutOfCombatRecoverySettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
OutOfCombatRecoverySettings(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 OutOfCombatRecovery.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public static OutOfCombatRecovery.Settings parse(Map<String, String> values) {
|
||||
Objects.requireNonNull(values);
|
||||
try {
|
||||
long delay = whole(values.get(DELAY), 0, MAX_SECONDS, DELAY);
|
||||
long interval = whole(values.get(INTERVAL), 1, MAX_SECONDS, INTERVAL);
|
||||
long amount = whole(values.get(AMOUNT), 0, 1_024, AMOUNT);
|
||||
return new OutOfCombatRecovery.Settings(Duration.ofSeconds(delay), Duration.ofSeconds(interval),
|
||||
java.math.BigInteger.valueOf(amount));
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Out-of-combat recovery settings must be whole numbers in range", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static long whole(String raw, long minimum, long maximum, String key) {
|
||||
var value = new java.math.BigInteger(raw);
|
||||
long result = value.longValueExact();
|
||||
if (result < minimum || result > maximum) throw new IllegalArgumentException(key + " is outside its supported range");
|
||||
return result;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String raw) {
|
||||
Objects.requireNonNull(key); Objects.requireNonNull(raw);
|
||||
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
|
||||
String accepted;
|
||||
try { accepted = new java.math.BigInteger(raw).toString(); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Setting must be a whole number", invalid); }
|
||||
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted); parse(preview);
|
||||
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("Out-of-combat recovery configuration save failed; effective settings unchanged", failure));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish only an acknowledged newest persisted snapshot. */
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values();
|
||||
publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
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", 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,166 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Purify;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
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 Purify settings; publication remains a server-thread caller decision. */
|
||||
public final class PurifySettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "purify-cooldown-seconds";
|
||||
public static final String RANGE = "purify-range-blocks";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, RANGE);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "15", RANGE, "12");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Purify.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-purify-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 PurifySettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
PurifySettings(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 Purify.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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(value); var preview = new LinkedHashMap<>(effectiveValues);
|
||||
preview.put(key, accepted); parse(preview);
|
||||
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("Purify 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 raw) {
|
||||
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static Purify.Settings parse(Map<String, String> values) {
|
||||
try { return new Purify.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RANGE))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Purify settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new 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,113 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.passive.QuickerRegenerationII;
|
||||
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.util.LinkedHashMap;
|
||||
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;
|
||||
|
||||
/** Durable save-before-publication Quicker Regeneration II balance. */
|
||||
public final class QuickerRegenerationIISettings implements AutoCloseable {
|
||||
public static final String KEY = "quicker-regeneration-ii-bonus-healing";
|
||||
public static final String DEFAULT = "2";
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String value, QuickerRegenerationII.Settings settings, long sequence) { }
|
||||
private final Path directory; private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-quicker-regeneration-ii-configuration"));
|
||||
private volatile String effective; private String persisted; private long persistedSequence, publishedSequence;
|
||||
private boolean closed; private int pending; private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public QuickerRegenerationIISettings(Path directory, String initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
QuickerRegenerationIISettings(Path directory, String initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
|
||||
effective = canonical(initial); persisted = effective;
|
||||
}
|
||||
public static QuickerRegenerationII.Settings parse(String raw) {
|
||||
try { return new QuickerRegenerationII.Settings(new java.math.BigInteger(raw)); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Bonus must be a whole number", invalid); }
|
||||
}
|
||||
private static String canonical(String raw) { var parsed = parse(raw); return parsed.bonus().toString(); }
|
||||
public String value() { return effective; }
|
||||
public QuickerRegenerationII.Settings settings() { return parse(effective); }
|
||||
public CompletionStage<Update> set(String raw) {
|
||||
String accepted = canonical(raw);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed")); pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try { save(accepted); persisted = accepted; return new Update(accepted, parse(accepted), ++persistedSequence); }
|
||||
catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Quicker Regeneration II configuration save failed; effective value unchanged", failure));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
|
||||
}
|
||||
}
|
||||
public synchronized boolean publish(Update update) {
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
effective = canonical(update.value()); publishedSequence = update.sequence(); return true;
|
||||
}
|
||||
private void save(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 key)) throw new IOException("Invalid configuration key");
|
||||
document.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(KEY, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
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", 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,113 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.passive.QuickerRegenerationI;
|
||||
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.util.LinkedHashMap;
|
||||
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;
|
||||
|
||||
/** Durable save-before-publication Quicker Regeneration I balance. */
|
||||
public final class QuickerRegenerationSettings implements AutoCloseable {
|
||||
public static final String KEY = "quicker-regeneration-i-bonus-healing";
|
||||
public static final String DEFAULT = "1";
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String value, QuickerRegenerationI.Settings settings, long sequence) { }
|
||||
private final Path directory; private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-quicker-regeneration-i-configuration"));
|
||||
private volatile String effective; private String persisted; private long persistedSequence, publishedSequence;
|
||||
private boolean closed; private int pending; private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public QuickerRegenerationSettings(Path directory, String initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
QuickerRegenerationSettings(Path directory, String initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
|
||||
effective = canonical(initial); persisted = effective;
|
||||
}
|
||||
public static QuickerRegenerationI.Settings parse(String raw) {
|
||||
try { return new QuickerRegenerationI.Settings(new java.math.BigInteger(raw)); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Bonus must be a whole number", invalid); }
|
||||
}
|
||||
private static String canonical(String raw) { var parsed = parse(raw); return parsed.bonus().toString(); }
|
||||
public String value() { return effective; }
|
||||
public QuickerRegenerationI.Settings settings() { return parse(effective); }
|
||||
public CompletionStage<Update> set(String raw) {
|
||||
String accepted = canonical(raw);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed")); pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try { save(accepted); persisted = accepted; return new Update(accepted, parse(accepted), ++persistedSequence); }
|
||||
catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Quicker Regeneration I configuration save failed; effective value unchanged", failure));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
|
||||
}
|
||||
}
|
||||
public synchronized boolean publish(Update update) {
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
effective = canonical(update.value()); publishedSequence = update.sequence(); return true;
|
||||
}
|
||||
private void save(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 key)) throw new IOException("Invalid configuration key");
|
||||
document.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(KEY, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
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", 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,172 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.RallyingCry;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
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 Rallying Cry settings; publication remains a server-thread caller decision. */
|
||||
public final class RallyingCrySettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "rallying-cry-cooldown-seconds";
|
||||
public static final String RADIUS = "rallying-cry-radius-blocks";
|
||||
public static final String DURATION = "rallying-cry-duration-seconds";
|
||||
public static final String SPEED = "rallying-cry-speed-amplifier";
|
||||
public static final String BONUS = "rallying-cry-damage-bonus-percent";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, BONUS, DURATION, RADIUS, SPEED);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
COOLDOWN, "25", RADIUS, "8", DURATION, "6", SPEED, "0", BONUS, "20");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, RallyingCry.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-rallying-cry-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 RallyingCrySettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
RallyingCrySettings(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 RallyingCry.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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(value); var preview = new LinkedHashMap<>(effectiveValues);
|
||||
preview.put(key, accepted); parse(preview);
|
||||
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("Rallying Cry 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 raw) {
|
||||
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static RallyingCry.Settings parse(Map<String, String> values) {
|
||||
try { return new RallyingCry.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RADIUS)),
|
||||
seconds(values.get(DURATION)), new BigDecimal(values.get(SPEED)).intValueExact(),
|
||||
new BigDecimal(values.get(BONUS))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Rallying Cry settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new 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,177 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Sanctuary;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
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 Sanctuary settings; publication remains a server-thread caller decision. */
|
||||
public final class SanctuarySettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "sanctuary-cooldown-seconds";
|
||||
public static final String RADIUS = "sanctuary-radius-blocks";
|
||||
public static final String VERTICAL = "sanctuary-vertical-range-blocks";
|
||||
public static final String DURATION = "sanctuary-duration-seconds";
|
||||
public static final String INTERVAL = "sanctuary-pulse-interval-seconds";
|
||||
public static final String HEALING = "sanctuary-healing-per-pulse";
|
||||
public static final String REDUCTION = "sanctuary-damage-reduction-percent";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, RADIUS, VERTICAL, DURATION, INTERVAL, HEALING, REDUCTION);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
COOLDOWN, "45", RADIUS, "6", VERTICAL, "3", DURATION, "6", INTERVAL, "1", HEALING, "2", REDUCTION, "20");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Sanctuary.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-sanctuary-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 SanctuarySettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
SanctuarySettings(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 Sanctuary.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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(value); var preview = new LinkedHashMap<>(effectiveValues);
|
||||
preview.put(key, accepted); parse(preview);
|
||||
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("Sanctuary 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 raw) {
|
||||
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static Sanctuary.Settings parse(Map<String, String> values) {
|
||||
try { return new Sanctuary.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RADIUS)),
|
||||
finiteNonnegative(values.get(VERTICAL)), seconds(values.get(DURATION)), seconds(values.get(INTERVAL)),
|
||||
new BigDecimal(values.get(HEALING)), new BigDecimal(values.get(REDUCTION))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Sanctuary settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
private static double finiteNonnegative(String value) {
|
||||
double result = finite(value); if (result < 0) throw new IllegalArgumentException("Setting is negative"); 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,179 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.WarBanner;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
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 WarBanner settings; publication remains a server-thread caller decision. */
|
||||
public final class WarBannerSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "war-banner-cooldown-seconds";
|
||||
public static final String PLACEMENT_RANGE = "war-banner-placement-range-blocks";
|
||||
public static final String RADIUS = "war-banner-radius-blocks";
|
||||
public static final String VERTICAL = "war-banner-vertical-range-blocks";
|
||||
public static final String DURATION = "war-banner-duration-seconds";
|
||||
public static final String DAMAGE_BONUS = "war-banner-damage-bonus-percent";
|
||||
public static final String KNOCKBACK_RESISTANCE = "war-banner-knockback-resistance-percent";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, PLACEMENT_RANGE, RADIUS, VERTICAL, DURATION,
|
||||
DAMAGE_BONUS, KNOCKBACK_RESISTANCE);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
COOLDOWN, "45", PLACEMENT_RANGE, "12", RADIUS, "8", VERTICAL, "4", DURATION, "10",
|
||||
DAMAGE_BONUS, "25", KNOCKBACK_RESISTANCE, "50");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, WarBanner.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-war-banner-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 WarBannerSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
WarBannerSettings(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 WarBanner.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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(value); var preview = new LinkedHashMap<>(effectiveValues);
|
||||
preview.put(key, accepted); parse(preview);
|
||||
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("WarBanner 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 raw) {
|
||||
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static WarBanner.Settings parse(Map<String, String> values) {
|
||||
try { return new WarBanner.Settings(seconds(values.get(COOLDOWN)), finite(values.get(PLACEMENT_RANGE)),
|
||||
finite(values.get(RADIUS)), finiteNonnegative(values.get(VERTICAL)), seconds(values.get(DURATION)),
|
||||
new BigDecimal(values.get(DAMAGE_BONUS)), new BigDecimal(values.get(KNOCKBACK_RESISTANCE))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("WarBanner settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
private static double finiteNonnegative(String value) {
|
||||
double result = finite(value); if (result < 0) throw new IllegalArgumentException("Setting is negative"); 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); }
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public final class CombatContributions implements AutoCloseable {
|
||||
private final CombatRewardRules rules;
|
||||
private final Map<UUID, Map<UUID, Instant>> targets = new HashMap<>();
|
||||
private final Map<UUID, Map<UUID, Map<UUID, Instant>>> supports = new HashMap<>();
|
||||
private final Map<UUID, Map<UUID, Instant>> effectiveHealing = new HashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
public CombatContributions(CombatRewardRules rules) { this.rules = Objects.requireNonNull(rules); }
|
||||
@@ -34,6 +35,14 @@ public final class CombatContributions implements AutoCloseable {
|
||||
.merge(supportingCharacter, acceptedAt, (previous, next) -> previous.isAfter(next) ? previous : next);
|
||||
}
|
||||
|
||||
/** Records effective healing that may qualify through any target the aided character directly contributes to. */
|
||||
public void support(UUID aidedCharacter, UUID supportingCharacter, Instant acceptedAt) {
|
||||
Objects.requireNonNull(aidedCharacter); Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(acceptedAt);
|
||||
if (closed) return;
|
||||
effectiveHealing.computeIfAbsent(aidedCharacter, ignored -> new HashMap<>())
|
||||
.merge(supportingCharacter, 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();
|
||||
@@ -45,8 +54,12 @@ public final class CombatContributions implements AutoCloseable {
|
||||
if (rules.contributes(acceptedAt, deathAt)) eligible.add(character);
|
||||
});
|
||||
for (UUID aided : Set.copyOf(eligible)) {
|
||||
var supporters = assistance == null ? null : assistance.get(aided);
|
||||
if (supporters != null) supporters.forEach((supporter, acceptedAt) -> {
|
||||
var targeted = assistance == null ? null : assistance.get(aided);
|
||||
if (targeted != null) targeted.forEach((supporter, acceptedAt) -> {
|
||||
if (supports(acceptedAt, deathAt)) eligible.add(supporter);
|
||||
});
|
||||
var healing = effectiveHealing.get(aided);
|
||||
if (healing != null) healing.forEach((supporter, acceptedAt) -> {
|
||||
if (supports(acceptedAt, deathAt)) eligible.add(supporter);
|
||||
});
|
||||
}
|
||||
@@ -67,11 +80,14 @@ public final class CombatContributions implements AutoCloseable {
|
||||
byAlly.values().removeIf(Map::isEmpty);
|
||||
});
|
||||
supports.values().removeIf(Map::isEmpty);
|
||||
effectiveHealing.values().forEach(bySupporter ->
|
||||
bySupporter.entrySet().removeIf(entry -> !supports(entry.getValue(), now)));
|
||||
effectiveHealing.values().removeIf(Map::isEmpty);
|
||||
}
|
||||
|
||||
private static boolean supports(Instant acceptedAt, Instant defeatAt) {
|
||||
return !acceptedAt.isAfter(defeatAt) && !defeatAt.isAfter(acceptedAt.plus(SUPPORT_LIFETIME));
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; targets.clear(); supports.clear(); }
|
||||
@Override public void close() { closed = true; targets.clear(); supports.clear(); effectiveHealing.clear(); }
|
||||
}
|
||||
@@ -60,7 +60,7 @@ public final class AdminRevival implements Listener, AutoCloseable {
|
||||
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival could not recheck that Tail; no item changed.");view.close();return;}if(!view.current()||found.isEmpty()||!eligible(found.orElseThrow(),choice.candidate.identity)||!unchanged(view.target,choice.candidate)){view.admin.sendMessage("That Tail selection is stale; no item changed.");view.close();return;}reserve(view,choice.candidate,found.orElseThrow());}));}
|
||||
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation);}));}
|
||||
private static int emptySlot(Player target,int source){for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40||slot==source)continue;var 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());var sheet=sheets.create(provisional);var 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.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
|
||||
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.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());var sheet=sheets.create(provisional);var 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.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
|
||||
private boolean installFinal(Player target,CharacterRecord record){int found=-1;for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;if(sheets.identify(target.getInventory().getItem(slot)).filter(record.id()::equals).isEmpty())continue;if(found>=0)return false;found=slot;}if(found<0)return false;target.getInventory().setItem(found,sheets.create(record));target.updateInventory();return true;}
|
||||
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){}}
|
||||
|
||||
@@ -39,6 +39,24 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.ability.Hex hex;
|
||||
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
|
||||
private games.dmg.fantasystick.ability.Inspire inspire;
|
||||
private games.dmg.fantasystick.persistence.MendingLightSettings mendingLightSettings;
|
||||
private games.dmg.fantasystick.ability.MendingLight mendingLight;
|
||||
private games.dmg.fantasystick.persistence.PurifySettings purifySettings;
|
||||
private games.dmg.fantasystick.ability.Purify purify;
|
||||
private games.dmg.fantasystick.persistence.GuardianBondSettings guardianBondSettings;
|
||||
private games.dmg.fantasystick.ability.GuardianBond guardianBond;
|
||||
private games.dmg.fantasystick.persistence.RallyingCrySettings rallyingCrySettings;
|
||||
private games.dmg.fantasystick.ability.RallyingCry rallyingCry;
|
||||
private games.dmg.fantasystick.persistence.FrostNovaSettings frostNovaSettings;
|
||||
private games.dmg.fantasystick.ability.FrostNova frostNova;
|
||||
private games.dmg.fantasystick.persistence.ChainLightningSettings chainLightningSettings;
|
||||
private games.dmg.fantasystick.ability.ChainLightning chainLightning;
|
||||
private games.dmg.fantasystick.persistence.SanctuarySettings sanctuarySettings;
|
||||
private games.dmg.fantasystick.ability.Sanctuary sanctuary;
|
||||
private games.dmg.fantasystick.persistence.WarBannerSettings warBannerSettings;
|
||||
private games.dmg.fantasystick.ability.WarBanner warBanner;
|
||||
private games.dmg.fantasystick.persistence.MeteorSettings meteorSettings;
|
||||
private games.dmg.fantasystick.ability.Meteor meteor;
|
||||
private AdminCharacterIssuer characterIssuer;
|
||||
private AdminRevival adminRevival;
|
||||
private AdminExperienceGrant experienceGrant;
|
||||
@@ -48,6 +66,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.persistence.PartySettings partySettings;
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
|
||||
private OutOfCombatRecovery outOfCombatRecovery;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationI quickerRegeneration;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationIISettings quickerRegenerationIISettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
|
||||
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
|
||||
private games.dmg.fantasystick.passive.HealthSteal healthSteal;
|
||||
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
|
||||
@@ -217,10 +244,94 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
this.inspireSettings=Objects.requireNonNull(inspireSettings);this.inspire=Objects.requireNonNull(inspire);
|
||||
}
|
||||
|
||||
public void attachMendingLight(games.dmg.fantasystick.persistence.MendingLightSettings settings,
|
||||
games.dmg.fantasystick.ability.MendingLight effect) {
|
||||
if (closed || mendingLightSettings != null) throw new IllegalStateException("Mending Light already attached or command closed");
|
||||
mendingLightSettings = Objects.requireNonNull(settings); mendingLight = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachPurify(games.dmg.fantasystick.persistence.PurifySettings settings,
|
||||
games.dmg.fantasystick.ability.Purify effect) {
|
||||
if (closed || purifySettings != null) throw new IllegalStateException("Purify already attached or command closed");
|
||||
purifySettings = Objects.requireNonNull(settings); purify = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachGuardianBond(games.dmg.fantasystick.persistence.GuardianBondSettings settings,
|
||||
games.dmg.fantasystick.ability.GuardianBond effect) {
|
||||
if (closed || guardianBondSettings != null) throw new IllegalStateException("Guardian Bond already attached or command closed");
|
||||
guardianBondSettings = Objects.requireNonNull(settings); guardianBond = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachRallyingCry(games.dmg.fantasystick.persistence.RallyingCrySettings settings,
|
||||
games.dmg.fantasystick.ability.RallyingCry effect) {
|
||||
if (closed || rallyingCrySettings != null) throw new IllegalStateException("Rallying Cry already attached or command closed");
|
||||
rallyingCrySettings = Objects.requireNonNull(settings); rallyingCry = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachFrostNova(games.dmg.fantasystick.persistence.FrostNovaSettings settings,
|
||||
games.dmg.fantasystick.ability.FrostNova effect) {
|
||||
if (closed || frostNovaSettings != null) throw new IllegalStateException("Frost Nova already attached or command closed");
|
||||
frostNovaSettings = Objects.requireNonNull(settings); frostNova = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachChainLightning(games.dmg.fantasystick.persistence.ChainLightningSettings settings,
|
||||
games.dmg.fantasystick.ability.ChainLightning effect) {
|
||||
if (closed || chainLightningSettings != null) throw new IllegalStateException("Chain Lightning already attached or command closed");
|
||||
chainLightningSettings = Objects.requireNonNull(settings); chainLightning = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachSanctuary(games.dmg.fantasystick.persistence.SanctuarySettings settings,
|
||||
games.dmg.fantasystick.ability.Sanctuary effect) {
|
||||
if (closed || sanctuarySettings != null) throw new IllegalStateException("Sanctuary already attached or command closed");
|
||||
sanctuarySettings = Objects.requireNonNull(settings); sanctuary = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachWarBanner(games.dmg.fantasystick.persistence.WarBannerSettings settings,
|
||||
games.dmg.fantasystick.ability.WarBanner effect) {
|
||||
if (closed || warBannerSettings != null) throw new IllegalStateException("War Banner already attached or command closed");
|
||||
warBannerSettings = Objects.requireNonNull(settings); warBanner = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachMeteor(games.dmg.fantasystick.persistence.MeteorSettings settings,
|
||||
games.dmg.fantasystick.ability.Meteor effect) {
|
||||
if (closed || meteorSettings != null) throw new IllegalStateException("Meteor already attached or command closed");
|
||||
meteorSettings = Objects.requireNonNull(settings); meteor = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
|
||||
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
|
||||
public void attachOutOfCombatRecovery(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings settings,
|
||||
OutOfCombatRecovery recovery) {
|
||||
if (closed || outOfCombatRecoverySettings != null) throw new IllegalStateException("Out-of-combat recovery already attached or command closed");
|
||||
outOfCombatRecoverySettings = Objects.requireNonNull(settings);
|
||||
outOfCombatRecovery = Objects.requireNonNull(recovery);
|
||||
}
|
||||
|
||||
public void attachQuickerRegeneration(games.dmg.fantasystick.persistence.QuickerRegenerationSettings settings,
|
||||
games.dmg.fantasystick.passive.QuickerRegenerationI passive,
|
||||
games.dmg.fantasystick.passive.PassiveRegistry registry) {
|
||||
if (closed || quickerRegenerationSettings != null) throw new IllegalStateException("Quicker Regeneration I already attached or command closed");
|
||||
quickerRegenerationSettings = Objects.requireNonNull(settings); quickerRegeneration = Objects.requireNonNull(passive);
|
||||
passiveRegistry = Objects.requireNonNull(registry);
|
||||
}
|
||||
|
||||
public void attachQuickerRegenerationII(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings settings,
|
||||
games.dmg.fantasystick.passive.QuickerRegenerationII passive,
|
||||
games.dmg.fantasystick.passive.PassiveRegistry registry) {
|
||||
if (closed || quickerRegenerationIISettings != null) throw new IllegalStateException("Quicker Regeneration II already attached or command closed");
|
||||
quickerRegenerationIISettings = Objects.requireNonNull(settings); quickerRegenerationII = Objects.requireNonNull(passive);
|
||||
if (passiveRegistry != Objects.requireNonNull(registry)) throw new IllegalArgumentException("Passive registry mismatch");
|
||||
}
|
||||
|
||||
public void attachHealthSteal(games.dmg.fantasystick.persistence.HealthStealSettings settings,
|
||||
games.dmg.fantasystick.passive.HealthSteal passive,
|
||||
games.dmg.fantasystick.passive.PassiveRegistry registry) {
|
||||
if (closed || healthStealSettings != null) throw new IllegalStateException("Health Steal already attached or command closed");
|
||||
healthStealSettings = Objects.requireNonNull(settings); healthSteal = Objects.requireNonNull(passive);
|
||||
if (passiveRegistry != Objects.requireNonNull(registry)) throw new IllegalArgumentException("Passive registry mismatch");
|
||||
}
|
||||
|
||||
public void attachExperienceGrant(AdminExperienceGrant grant) {
|
||||
if (closed || experienceGrant != null) throw new IllegalStateException("Experience grant already attached or command closed");
|
||||
@@ -399,6 +510,21 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)) { partyConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)) { sheetResourcePackConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)) { adminReviveConfiguration(sender,args); return; }
|
||||
if (outOfCombatRecoverySettings != null
|
||||
&& games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys().contains(args[1])) {
|
||||
outOfCombatRecoveryConfiguration(sender, args); return;
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY)
|
||||
&& quickerRegenerationSettings != null) {
|
||||
quickerRegenerationConfiguration(sender, args); return;
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY)
|
||||
&& quickerRegenerationIISettings != null) {
|
||||
quickerRegenerationIIConfiguration(sender, args); return;
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.HealthStealSettings.KEY) && healthStealSettings != null) {
|
||||
healthStealConfiguration(sender, args); return;
|
||||
}
|
||||
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
|
||||
longbowConfiguration(sender, args); return;
|
||||
}
|
||||
@@ -420,6 +546,33 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (inspireSettings != null && games.dmg.fantasystick.persistence.InspireSettings.keys().contains(args[1])) {
|
||||
inspireConfiguration(sender, args); return;
|
||||
}
|
||||
if (mendingLightSettings != null && games.dmg.fantasystick.persistence.MendingLightSettings.keys().contains(args[1])) {
|
||||
mendingLightConfiguration(sender, args); return;
|
||||
}
|
||||
if (purifySettings != null && games.dmg.fantasystick.persistence.PurifySettings.keys().contains(args[1])) {
|
||||
purifyConfiguration(sender, args); return;
|
||||
}
|
||||
if (guardianBondSettings != null && games.dmg.fantasystick.persistence.GuardianBondSettings.keys().contains(args[1])) {
|
||||
guardianBondConfiguration(sender, args); return;
|
||||
}
|
||||
if (rallyingCrySettings != null && games.dmg.fantasystick.persistence.RallyingCrySettings.keys().contains(args[1])) {
|
||||
rallyingCryConfiguration(sender, args); return;
|
||||
}
|
||||
if (frostNovaSettings != null && games.dmg.fantasystick.persistence.FrostNovaSettings.keys().contains(args[1])) {
|
||||
frostNovaConfiguration(sender, args); return;
|
||||
}
|
||||
if (chainLightningSettings != null && games.dmg.fantasystick.persistence.ChainLightningSettings.keys().contains(args[1])) {
|
||||
chainLightningConfiguration(sender, args); return;
|
||||
}
|
||||
if (sanctuarySettings != null && games.dmg.fantasystick.persistence.SanctuarySettings.keys().contains(args[1])) {
|
||||
sanctuaryConfiguration(sender, args); return;
|
||||
}
|
||||
if (warBannerSettings != null && games.dmg.fantasystick.persistence.WarBannerSettings.keys().contains(args[1])) {
|
||||
warBannerConfiguration(sender, args); return;
|
||||
}
|
||||
if (meteorSettings != null && games.dmg.fantasystick.persistence.MeteorSettings.keys().contains(args[1])) {
|
||||
meteorConfiguration(sender, args); return;
|
||||
}
|
||||
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
|
||||
configurationUsage(sender); return;
|
||||
}
|
||||
@@ -454,6 +607,126 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
private void outOfCombatRecoveryConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + outOfCombatRecoverySettings.value(key)
|
||||
+ " (applies to future recovery evaluations; the current activity timestamp is retained)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.Update> operation;
|
||||
try { operation = outOfCombatRecoverySettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Out-of-combat recovery 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;
|
||||
boolean applied = false;
|
||||
if (failure == null) {
|
||||
applied = outOfCombatRecoverySettings.publish(saved);
|
||||
if (applied) outOfCombatRecovery.configure(saved.settings());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) sender.sendMessage("Out-of-combat recovery configuration save failed; effective values are unchanged.");
|
||||
else if (!applied) sender.sendMessage("Saved request was superseded; effective " + key + "="
|
||||
+ outOfCombatRecoverySettings.value(key) + ".");
|
||||
else sender.sendMessage("Saved " + key + "=" + saved.value()
|
||||
+ "; future evaluations use it and current activity timestamps are retained.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void quickerRegenerationConfiguration(CommandSender sender, String[] args) {
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY + "="
|
||||
+ quickerRegenerationSettings.value() + " (applies to future recovery pulses only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickerRegenerationSettings.Update> operation;
|
||||
try { operation = quickerRegenerationSettings.set(args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Quicker Regeneration I setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-quicker-regeneration-i", failure); if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return; boolean applied = false;
|
||||
if (failure == null) {
|
||||
applied = quickerRegenerationSettings.publish(saved);
|
||||
if (applied) { quickerRegeneration.configure(saved.settings()); passiveRegistry.replace(quickerRegeneration.definition()); }
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) sender.sendMessage("Quicker Regeneration I configuration save failed; effective value is unchanged.");
|
||||
else if (!applied) sender.sendMessage("Saved request was superseded; effective "
|
||||
+ games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY + "=" + quickerRegenerationSettings.value() + ".");
|
||||
else sender.sendMessage("Saved " + games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY
|
||||
+ "=" + saved.value() + " for future recovery pulses.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void quickerRegenerationIIConfiguration(CommandSender sender, String[] args) {
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY + "="
|
||||
+ quickerRegenerationIISettings.value() + " (applies to future recovery pulses only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.Update> operation;
|
||||
try { operation = quickerRegenerationIISettings.set(args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Quicker Regeneration II setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-quicker-regeneration-ii", failure); if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return; boolean applied = false;
|
||||
if (failure == null) {
|
||||
applied = quickerRegenerationIISettings.publish(saved);
|
||||
if (applied) { quickerRegenerationII.configure(saved.settings()); passiveRegistry.replace(quickerRegenerationII.definition()); }
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) sender.sendMessage("Quicker Regeneration II configuration save failed; effective value is unchanged.");
|
||||
else if (!applied) sender.sendMessage("Saved request was superseded; effective "
|
||||
+ games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY + "=" + quickerRegenerationIISettings.value() + ".");
|
||||
else sender.sendMessage("Saved " + games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY
|
||||
+ "=" + saved.value() + " for future recovery pulses.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void healthStealConfiguration(CommandSender sender, String[] args) {
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.HealthStealSettings.KEY + "="
|
||||
+ healthStealSettings.value() + " (applies to later qualifying final blows only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HealthStealSettings.Update> operation;
|
||||
try { operation = healthStealSettings.set(args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Health Steal setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-health-steal", failure); if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return; boolean applied = false;
|
||||
if (failure == null) {
|
||||
applied = healthStealSettings.publish(saved);
|
||||
if (applied) { healthSteal.configure(saved.settings()); passiveRegistry.replace(healthSteal.definition()); }
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) sender.sendMessage("Health Steal configuration save failed; effective value is unchanged.");
|
||||
else if (!applied) sender.sendMessage("Saved request was superseded; effective "
|
||||
+ games.dmg.fantasystick.persistence.HealthStealSettings.KEY + "=" + healthStealSettings.value() + ".");
|
||||
else sender.sendMessage("Saved " + games.dmg.fantasystick.persistence.HealthStealSettings.KEY
|
||||
+ "=" + saved.value() + " for later qualifying final blows.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void longbowConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
@@ -519,6 +792,222 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void inspireConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+inspireSettings.value(key)+" (applies to future Inspire casts only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.InspireSettings.Update> operation;try{operation=inspireSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Inspire 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){inspire.configure(saved.settings());abilities.replace(inspire.definition());inspireSettings.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 Inspire casts; active effects and deadlines are unchanged.":"Inspire configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void mendingLightConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + mendingLightSettings.value(key) + " (applies to future Mending Light casts only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.MendingLightSettings.Update> operation;
|
||||
try { operation = mendingLightSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Mending Light 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) {
|
||||
mendingLight.configure(saved.settings()); abilities.replace(mendingLight.definition()); mendingLightSettings.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 Mending Light casts; active work and deadlines are unchanged."
|
||||
: "Mending Light configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void purifyConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + purifySettings.value(key) + " (applies to future Purify casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PurifySettings.Update> operation;
|
||||
try { operation = purifySettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Purify 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) {
|
||||
purify.configure(saved.settings()); abilities.replace(purify.definition()); purifySettings.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 Purify casts; active work and deadlines are unchanged."
|
||||
: "Purify configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void guardianBondConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + guardianBondSettings.value(key) + " (applies to future Guardian Bond casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GuardianBondSettings.Update> operation;
|
||||
try { operation = guardianBondSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Guardian Bond 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) {
|
||||
guardianBond.configure(saved.settings()); abilities.replace(guardianBond.definition()); guardianBondSettings.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 Guardian Bond casts; active bonds and deadlines are unchanged."
|
||||
: "Guardian Bond configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void rallyingCryConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + rallyingCrySettings.value(key) + " (applies to future Rallying Cry casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RallyingCrySettings.Update> operation;
|
||||
try { operation = rallyingCrySettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Rallying Cry 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) {
|
||||
rallyingCry.configure(saved.settings()); abilities.replace(rallyingCry.definition()); rallyingCrySettings.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 Rallying Cry casts; active cries and deadlines are unchanged."
|
||||
: "Rallying Cry configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void frostNovaConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + frostNovaSettings.value(key) + " (applies to future Frost Nova casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostNovaSettings.Update> operation;
|
||||
try { operation = frostNovaSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Frost Nova 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) {
|
||||
frostNova.configure(saved.settings()); abilities.replace(frostNova.definition()); frostNovaSettings.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 Nova casts; active slows and deadlines are unchanged."
|
||||
: "Frost Nova configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void chainLightningConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + chainLightningSettings.value(key) + " (applies to future Chain Lightning casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ChainLightningSettings.Update> operation;
|
||||
try { operation = chainLightningSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Chain Lightning 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) {
|
||||
chainLightning.configure(saved.settings()); abilities.replace(chainLightning.definition()); chainLightningSettings.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 Chain Lightning casts; active chains and deadlines are unchanged."
|
||||
: "Chain Lightning configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void sanctuaryConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + sanctuarySettings.value(key) + " (applies to future Sanctuary casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SanctuarySettings.Update> operation;
|
||||
try { operation = sanctuarySettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Sanctuary 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) {
|
||||
sanctuary.configure(saved.settings()); abilities.replace(sanctuary.definition()); sanctuarySettings.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 Sanctuary casts; active areas and deadlines are unchanged."
|
||||
: "Sanctuary configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void warBannerConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) { sender.sendMessage(key + "=" + warBannerSettings.value(key) + " (applies to future War Banner casts only)"); return; }
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.WarBannerSettings.Update> operation;
|
||||
try { operation = warBannerSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) { sender.sendMessage("War Banner 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) { warBanner.configure(saved.settings()); abilities.replace(warBanner.definition()); warBannerSettings.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 War Banner casts; active areas and deadlines are unchanged."
|
||||
: "War Banner configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void meteorConfiguration(CommandSender sender, String[] args) {
|
||||
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+meteorSettings.value(key)+" (applies to future Meteor casts only)");return;}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.MeteorSettings.Update> operation;
|
||||
try{operation=meteorSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Meteor 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){meteor.configure(saved.settings());abilities.replace(meteor.definition());meteorSettings.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 Meteor casts; active warnings and deadlines are unchanged.":"Meteor configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
|
||||
}
|
||||
|
||||
private void hexConfiguration(CommandSender sender, String[] args) {
|
||||
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+hexSettings.value(key)+" (applies to future Hex casts only)");return;}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HexSettings.Update> operation;
|
||||
@@ -665,6 +1154,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
.filter(org.bukkit.entity.Player::isOnline).map(org.bukkit.entity.Player::getName).toList(), args[1]);
|
||||
if (args.length == 2 && args[0].equals("config")) {
|
||||
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.RoleplaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.PartySettings.KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY);
|
||||
keys.addAll(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys());
|
||||
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY);
|
||||
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY);
|
||||
keys.add(games.dmg.fantasystick.persistence.HealthStealSettings.KEY);
|
||||
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
|
||||
@@ -673,6 +1166,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
keys.addAll(games.dmg.fantasystick.persistence.ShieldBashSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.HexSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.InspireSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.MendingLightSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.PurifySettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.GuardianBondSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.RallyingCrySettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.FrostNovaSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.ChainLightningSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.SanctuarySettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.WarBannerSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.MeteorSettings.keys());
|
||||
return matching(keys, args[1]);
|
||||
}
|
||||
if (args.length == 3 && args[0].equals("config")) {
|
||||
@@ -682,6 +1184,29 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)&&roleplaySettings!=null)return matching(List.of("false","true"),args[2]);
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)&&partySettings!=null){String current=partySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.PartySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.PartySettings.DEFAULT),args[2]);}
|
||||
if(sheetResourcePackSettings!=null&&(args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)))return matching(List.of(sheetResourcePackSettings.value(args[1])),args[2]);
|
||||
if (outOfCombatRecoverySettings != null
|
||||
&& games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys().contains(args[1])) {
|
||||
String current = outOfCombatRecoverySettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY)
|
||||
&& quickerRegenerationSettings != null) {
|
||||
String current = quickerRegenerationSettings.value();
|
||||
return matching(current.equals(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.DEFAULT)
|
||||
? List.of(current) : List.of(current, games.dmg.fantasystick.persistence.QuickerRegenerationSettings.DEFAULT), args[2]);
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY)
|
||||
&& quickerRegenerationIISettings != null) {
|
||||
String current = quickerRegenerationIISettings.value();
|
||||
return matching(current.equals(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.DEFAULT)
|
||||
? List.of(current) : List.of(current, games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.DEFAULT), args[2]);
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.HealthStealSettings.KEY) && healthStealSettings != null) {
|
||||
String current = healthStealSettings.value();
|
||||
return matching(current.equals(games.dmg.fantasystick.persistence.HealthStealSettings.DEFAULT)
|
||||
? List.of(current) : List.of(current, games.dmg.fantasystick.persistence.HealthStealSettings.DEFAULT), 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]);
|
||||
@@ -717,6 +1242,56 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if(inspireSettings!=null&&games.dmg.fantasystick.persistence.InspireSettings.keys().contains(args[1])){String current=inspireSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.InspireSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
|
||||
if (mendingLightSettings != null && games.dmg.fantasystick.persistence.MendingLightSettings.keys().contains(args[1])) {
|
||||
String current = mendingLightSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.MendingLightSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (purifySettings != null && games.dmg.fantasystick.persistence.PurifySettings.keys().contains(args[1])) {
|
||||
String current = purifySettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.PurifySettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (guardianBondSettings != null
|
||||
&& games.dmg.fantasystick.persistence.GuardianBondSettings.keys().contains(args[1])) {
|
||||
String current = guardianBondSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.GuardianBondSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (rallyingCrySettings != null
|
||||
&& games.dmg.fantasystick.persistence.RallyingCrySettings.keys().contains(args[1])) {
|
||||
String current = rallyingCrySettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.RallyingCrySettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (frostNovaSettings != null
|
||||
&& games.dmg.fantasystick.persistence.FrostNovaSettings.keys().contains(args[1])) {
|
||||
String current = frostNovaSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.FrostNovaSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (chainLightningSettings != null
|
||||
&& games.dmg.fantasystick.persistence.ChainLightningSettings.keys().contains(args[1])) {
|
||||
String current = chainLightningSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.ChainLightningSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (sanctuarySettings != null
|
||||
&& games.dmg.fantasystick.persistence.SanctuarySettings.keys().contains(args[1])) {
|
||||
String current = sanctuarySettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.SanctuarySettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (warBannerSettings != null
|
||||
&& games.dmg.fantasystick.persistence.WarBannerSettings.keys().contains(args[1])) {
|
||||
String current = warBannerSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.WarBannerSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (meteorSettings != null && games.dmg.fantasystick.persistence.MeteorSettings.keys().contains(args[1])) {
|
||||
String current=meteorSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.MeteorSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@@ -345,8 +345,10 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
var selected = sessions.selectedControl(event.getPlayer(), event.getItem()).orElse(null);
|
||||
if (selected != null && useGesture(selected)) {
|
||||
org.bukkit.entity.Entity target = selected.ability().equals("hex")
|
||||
|| selected.ability().equals("mending_light")
|
||||
|| selected.ability().equals("guardian_bond")
|
||||
? event.getPlayer().getTargetEntity(64)
|
||||
: selected.ability().equals("inspire")
|
||||
: selected.ability().equals("inspire") || selected.ability().equals("purify")
|
||||
? event.getPlayer().isSneaking() ? event.getPlayer() : event.getPlayer().getTargetEntity(64)
|
||||
: null;
|
||||
emit(event.getPlayer(), event.getItem(), selected, ControlInput.Gesture.USE, target, 0, 0);
|
||||
@@ -422,12 +424,11 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
|
||||
private void emit(Player player, ItemStack item, EquipmentSessions.SelectedControl selected,
|
||||
ControlInput.Gesture gesture, org.bukkit.entity.Entity target, int heldTicks, float strength) {
|
||||
var captured = item.clone();
|
||||
var kind = selected.slot() == 0 ? ControlInput.Kind.BOOK
|
||||
: selected.slot() == 1 ? ControlInput.Kind.BASIC_ATTACK : ControlInput.Kind.ABILITY;
|
||||
var input = new ControlInput(player, selected.session(), selected.slot(), selected.ability(), kind,
|
||||
gesture, target, heldTicks, strength,
|
||||
() -> !closed && sessions.selectedControl(player, captured).filter(selected::equals).isPresent());
|
||||
() -> !closed && sessions.currentControl(player, selected));
|
||||
if (!input.stillCurrent()) return;
|
||||
try { inputs.accept(input); }
|
||||
catch (RuntimeException failure) { report(player.getUniqueId(), selected.session().session(), failure); }
|
||||
|
||||
@@ -49,11 +49,13 @@ 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 SessionControls controls = new SessionControls();
|
||||
private SessionControls controls;
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private final Map<UUID, Attempt> pending = new HashMap<>();
|
||||
private final Map<UUID, Attempt> owned = new HashMap<>();
|
||||
private final Map<UUID, Attempt> active = new HashMap<>();
|
||||
private final java.util.ArrayDeque<UUID> cooldownRefreshOrder = new java.util.ArrayDeque<>();
|
||||
private final java.util.Set<UUID> cooldownRefreshQueued = new java.util.HashSet<>();
|
||||
// Covers reentrant inventory setters and client sync, not just asynchronous work.
|
||||
private final Map<UUID, Attempt> transitioning = new HashMap<>();
|
||||
private Consumer<UUID> cursorCustody = ignored -> { };
|
||||
@@ -82,6 +84,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean effectsStarted;
|
||||
String previousGameMode;
|
||||
boolean modeStarted;
|
||||
Instant nextCooldownVisualChange;
|
||||
games.dmg.fantasystick.persistence.OrdinaryVitals ordinaryVitals;
|
||||
boolean vitalsStarted;
|
||||
boolean healthSaveRequested;
|
||||
@@ -125,6 +128,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
var initialSwitchDelay = Objects.requireNonNull(switchDelay);
|
||||
this.switchDelay = () -> initialSwitchDelay;
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.controls = new SessionControls(new games.dmg.fantasystick.ability.AbilityRegistry(), clock);
|
||||
if (switchDelay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.notices = Objects.requireNonNull(notices);
|
||||
@@ -163,7 +167,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
/** 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));
|
||||
controls = new SessionControls(Objects.requireNonNull(registry), clock);
|
||||
}
|
||||
|
||||
/** Install before exposing entry; equipment-only adapter tests can omit the health overlay. */
|
||||
@@ -242,6 +246,12 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
.map(attempt -> new Active(attempt.owner, attempt.session, attempt.character)).findFirst();
|
||||
}
|
||||
|
||||
/** Current native holder for a still-authorized server-thread action. */
|
||||
public synchronized Optional<Player> currentPlayer(Active session) {
|
||||
var attempt = active.get(session.player());
|
||||
return current(session) && attempt != null ? Optional.of(attempt.player) : Optional.empty();
|
||||
}
|
||||
|
||||
/** Current transferable character snapshot for a still-authorized server-thread action. */
|
||||
public synchronized Optional<CharacterRecord> currentRecord(Active session) {
|
||||
var attempt = active.get(session.player());
|
||||
@@ -259,18 +269,70 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
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.
|
||||
}
|
||||
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns()))
|
||||
&& !refreshControls(attempt, session)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Bounded server-tick refresh of active cooldown controls; persisted deadlines are not mutated. */
|
||||
public synchronized void refreshCooldownControls(int maximumSessions) {
|
||||
if (maximumSessions <= 0) throw new IllegalArgumentException("Cooldown refresh bound must be positive");
|
||||
int checks = Math.min(maximumSessions, cooldownRefreshOrder.size());
|
||||
for (int checked = 0; checked < checks; checked++) {
|
||||
UUID owner = cooldownRefreshOrder.removeFirst();
|
||||
var attempt = active.get(owner);
|
||||
if (attempt == null || attempt.record == null || attempt.session == null) {
|
||||
cooldownRefreshQueued.remove(owner);
|
||||
continue;
|
||||
}
|
||||
cooldownRefreshOrder.addLast(owner);
|
||||
if (attempt.nextCooldownVisualChange != null && !clock.instant().isBefore(attempt.nextCooldownVisualChange)) {
|
||||
refreshControls(attempt, new Active(attempt.owner, attempt.session, attempt.character));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void queueCooldownRefresh(Attempt attempt) {
|
||||
if (cooldownRefreshQueued.add(attempt.owner)) cooldownRefreshOrder.addLast(attempt.owner);
|
||||
}
|
||||
|
||||
private boolean refreshControls(Attempt attempt, Active session) {
|
||||
var refreshed = controls.create(attempt.record, attempt.owner, attempt.session);
|
||||
boolean changed = false;
|
||||
for (int slot = 2; slot < 9; slot++) {
|
||||
if (active.get(attempt.owner) != attempt || !current(session)) return false;
|
||||
if (Objects.equals(attempt.player.getInventory().getItem(slot), refreshed[slot])) continue;
|
||||
attempt.player.getInventory().setItem(slot, refreshed[slot]);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) attempt.player.updateInventory(); // No planned native writes after this reentrant boundary.
|
||||
if (active.get(attempt.owner) != attempt || !current(session)) return false;
|
||||
attempt.nextCooldownVisualChange = nextCooldownVisualChange(attempt.record);
|
||||
return true;
|
||||
}
|
||||
|
||||
private Instant nextCooldownVisualChange(CharacterRecord record) {
|
||||
var now = clock.instant();
|
||||
return record.loadout().stream().filter(ability -> !ability.isEmpty())
|
||||
.map(record.cooldowns()::get).filter(Objects::nonNull).filter(deadline -> now.isBefore(deadline))
|
||||
.min(Instant::compareTo).orElse(null);
|
||||
}
|
||||
|
||||
/** Server-thread authorization for the currently held, issued control and saved loadout. */
|
||||
public synchronized boolean currentControl(Player player, SelectedControl selected) {
|
||||
if (selected == null) return false;
|
||||
var attempt = active.get(player.getUniqueId());
|
||||
if (closed || attempt == null || attempt.player != player || attempt.record == null
|
||||
|| !current(selected.session()) || selected.slot() != player.getInventory().getHeldItemSlot()) return false;
|
||||
var actual = player.getInventory().getItem(selected.slot());
|
||||
var identity = controls.identify(actual).orElse(null);
|
||||
if (identity == null || identity.slot() != selected.slot() || !identity.owner().equals(attempt.owner)
|
||||
|| !identity.session().equals(attempt.session)) return false;
|
||||
String ability = selected.slot() < 2 ? "" : controls.ability(actual).orElse("");
|
||||
return ability.equals(selected.ability()) && (selected.slot() < 2
|
||||
|| ability.equals(attempt.record.loadout().get(selected.slot() - 2)));
|
||||
}
|
||||
|
||||
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
|
||||
var attempt = active.get(player.getUniqueId());
|
||||
if (closed || attempt == null || attempt.player != player || !player.isOnline() || attempt.record == null
|
||||
@@ -456,7 +518,9 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
pending.remove(attempt.owner, attempt);
|
||||
if (!attempt.exitRequested) {
|
||||
attempt.record = record;
|
||||
attempt.nextCooldownVisualChange = nextCooldownVisualChange(record);
|
||||
active.put(attempt.owner, attempt);
|
||||
queueCooldownRefresh(attempt);
|
||||
attempt.presentationInstalled = true;
|
||||
try { presentation.activate(attempt.player, attempt.session, record); }
|
||||
catch (RuntimeException | LinkageError error) { withdrawPresentation(attempt); diagnostic(attempt, error); }
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.party.CombatSources;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
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.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
|
||||
/** Active-session damage clock and persist-before-native gradual recovery. */
|
||||
public final class OutOfCombatRecovery implements Listener, AutoCloseable {
|
||||
public record Settings(Duration delay, Duration interval, BigInteger amount) {
|
||||
private static final Duration MAX = Duration.ofSeconds(31_536_000);
|
||||
public Settings {
|
||||
Objects.requireNonNull(delay); Objects.requireNonNull(interval); Objects.requireNonNull(amount);
|
||||
if (delay.isNegative() || delay.compareTo(MAX) > 0 || interval.isZero() || interval.isNegative()
|
||||
|| interval.compareTo(MAX) > 0 || amount.signum() < 0
|
||||
|| amount.compareTo(BigInteger.valueOf(1_024)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid out-of-combat recovery settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(Duration.ofSeconds(30), Duration.ofSeconds(10), BigInteger.ONE);
|
||||
}
|
||||
}
|
||||
|
||||
public record Failure(UUID character, Throwable cause) { }
|
||||
|
||||
private static final int MAXIMUM_SESSION_CHECKS = 64;
|
||||
private static final class State {
|
||||
final EquipmentSessions.Active active;
|
||||
Instant activity;
|
||||
Instant lastAttempt;
|
||||
long generation;
|
||||
Object pending;
|
||||
State(EquipmentSessions.Active active, Instant activity) { this.active = active; this.activity = activity; }
|
||||
}
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<EntityRegainHealthEvent> healingEvents;
|
||||
private final Supplier<BigDecimal> nativeLimit;
|
||||
private final Consumer<Failure> failures;
|
||||
private final CombatSources sources;
|
||||
private java.util.function.Function<CharacterRecord, BigInteger> bonusHealing = ignored -> BigInteger.ZERO;
|
||||
private final Map<UUID, State> states = new HashMap<>();
|
||||
private final ArrayDeque<UUID> order = new ArrayDeque<>();
|
||||
private final Set<UUID> queued = new HashSet<>();
|
||||
private volatile Settings settings;
|
||||
private org.bukkit.scheduler.BukkitTask task;
|
||||
private boolean closed;
|
||||
|
||||
public OutOfCombatRecovery(Settings settings, CharacterStorage characters, EquipmentSessions sessions, Clock clock,
|
||||
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
|
||||
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures) {
|
||||
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.clock = Objects.requireNonNull(clock);
|
||||
this.nextTick = Objects.requireNonNull(nextTick); this.healingEvents = Objects.requireNonNull(healingEvents);
|
||||
this.nativeLimit = Objects.requireNonNull(nativeLimit); this.failures = Objects.requireNonNull(failures);
|
||||
sources = new CombatSources(sessions);
|
||||
}
|
||||
|
||||
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
|
||||
|
||||
/** Install the learned-passive bonus before recovery evaluation begins. */
|
||||
public synchronized void attachBonusHealing(java.util.function.Function<CharacterRecord, BigInteger> bonus) {
|
||||
if (closed || task != null || !states.isEmpty()) throw new IllegalStateException("Recovery evaluation already started");
|
||||
bonusHealing = Objects.requireNonNull(bonus);
|
||||
}
|
||||
|
||||
public synchronized void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin);
|
||||
if (closed || task != null) throw new IllegalStateException("Out-of-combat recovery is already installed or closed");
|
||||
task = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
|
||||
/** Bounded active-session evaluation. Exact deadlines are eligible, never replayed in a burst. */
|
||||
public synchronized void sweep() {
|
||||
if (closed) return;
|
||||
Instant now = clock.instant();
|
||||
var active = new HashMap<UUID, EquipmentSessions.ActiveCharacter>();
|
||||
for (var snapshot : sessions.activeCharacters()) {
|
||||
active.put(snapshot.session().player(), snapshot);
|
||||
var state = states.get(snapshot.session().player());
|
||||
if (state == null || !state.active.equals(snapshot.session())) {
|
||||
state = new State(snapshot.session(), now);
|
||||
states.put(snapshot.session().player(), state);
|
||||
}
|
||||
if (queued.add(snapshot.session().player())) order.addLast(snapshot.session().player());
|
||||
}
|
||||
for (UUID owner : java.util.List.copyOf(states.keySet())) {
|
||||
var snapshot = active.get(owner);
|
||||
if (snapshot == null || !states.get(owner).active.equals(snapshot.session())) remove(owner);
|
||||
}
|
||||
int checks = Math.min(MAXIMUM_SESSION_CHECKS, order.size());
|
||||
for (int checked = 0; checked < checks; checked++) {
|
||||
UUID owner = order.removeFirst();
|
||||
var state = states.get(owner);
|
||||
var snapshot = active.get(owner);
|
||||
if (state == null || snapshot == null || !state.active.equals(snapshot.session())) {
|
||||
remove(owner); continue;
|
||||
}
|
||||
order.addLast(owner);
|
||||
evaluate(state, snapshot.record(), now);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public synchronized void acceptedDamage(EntityDamageEvent event) {
|
||||
if (closed || event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0) return;
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
sessions.active(target.getUniqueId()).filter(sessions::current).ifPresent(this::reset);
|
||||
}
|
||||
var origin = sources.source(event);
|
||||
if (origin != null && origin.session() != null && sources.current(origin)) {
|
||||
reset(new EquipmentSessions.Active(origin.player(), origin.session(), origin.character()));
|
||||
}
|
||||
}
|
||||
|
||||
private void reset(EquipmentSessions.Active active) {
|
||||
Instant now = clock.instant();
|
||||
var state = states.get(active.player());
|
||||
if (state == null || !state.active.equals(active)) {
|
||||
state = new State(active, now);
|
||||
states.put(active.player(), state);
|
||||
if (queued.add(active.player())) order.addLast(active.player());
|
||||
} else {
|
||||
state.activity = now;
|
||||
state.lastAttempt = null;
|
||||
state.generation++;
|
||||
}
|
||||
}
|
||||
|
||||
private void evaluate(State state, CharacterRecord record, Instant now) {
|
||||
if (state.pending != null || !sessions.current(state.active)) return;
|
||||
var configured = settings;
|
||||
Instant eligible = state.lastAttempt == null
|
||||
? state.activity.plus(configured.delay()) : state.lastAttempt.plus(configured.interval());
|
||||
if (now.isBefore(eligible) || configured.amount().signum() == 0) return;
|
||||
var player = sessions.currentPlayer(state.active).orElse(null);
|
||||
if (player == null) return;
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) return;
|
||||
double current = player.getHealth();
|
||||
double maximum = Math.min(attribute.getValue(), record.maximumHealth().doubleValue());
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || current < 0 || maximum <= 0) return;
|
||||
if (current >= maximum) {
|
||||
state.lastAttempt = now; // Full-health checks are not banked for later replay.
|
||||
return;
|
||||
}
|
||||
BigInteger exactAmount;
|
||||
try { exactAmount = configured.amount().add(Objects.requireNonNull(bonusHealing.apply(record))); }
|
||||
catch (RuntimeException failure) { state.lastAttempt = now; report(record.id(), failure); return; }
|
||||
if (exactAmount.signum() < 0) { state.lastAttempt = now; report(record.id(), new IllegalStateException("Negative recovery bonus")); return; }
|
||||
double amount = exactAmount.bitLength() > 1023 ? maximum - current : exactAmount.doubleValue();
|
||||
double proposed = Math.min(maximum - current, amount);
|
||||
if (!Double.isFinite(proposed) || proposed <= 0) return;
|
||||
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event);
|
||||
double accepted = Math.min(proposed, Math.min(maximum - current, event.getAmount()));
|
||||
state.lastAttempt = now;
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
|
||||
Object token = new Object();
|
||||
state.pending = token;
|
||||
long generation = state.generation;
|
||||
BigInteger expected = record.revision();
|
||||
BigDecimal target = BigDecimal.valueOf(current + accepted);
|
||||
BigDecimal limit;
|
||||
try { limit = nativeLimit.get(); }
|
||||
catch (RuntimeException failure) { state.pending = null; state.lastAttempt = clock.instant(); report(record.id(), failure); return; }
|
||||
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> complete(state, token, generation, expected, current, saved, failure));
|
||||
} catch (RuntimeException rejected) {
|
||||
synchronized (OutOfCombatRecovery.this) {
|
||||
if (state.pending == token) { state.pending = null; state.lastAttempt = clock.instant(); }
|
||||
}
|
||||
report(record.id(), rejected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private synchronized void complete(State state, Object token, long generation, BigInteger expected,
|
||||
double nativeBefore, java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
if (state.pending != token) return;
|
||||
state.pending = null;
|
||||
if (failure != null || saved == null || saved.isEmpty()) {
|
||||
state.lastAttempt = clock.instant();
|
||||
report(state.active.character(), failure != null ? failure : new IllegalStateException("Character unavailable for recovery save"));
|
||||
return;
|
||||
}
|
||||
var persisted = saved.orElseThrow();
|
||||
boolean acknowledged = sessions.acknowledgeProgress(state.active, expected, persisted);
|
||||
if (!acknowledged) {
|
||||
report(state.active.character(), new IllegalStateException("Saved recovery could not synchronize with its active session"));
|
||||
return;
|
||||
}
|
||||
if (closed || generation != state.generation || states.get(state.active.player()) != state
|
||||
|| !sessions.current(state.active)) return;
|
||||
var player = sessions.currentPlayer(state.active).orElse(null);
|
||||
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) return;
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) return;
|
||||
double maximum = Math.min(attribute.getValue(), persisted.maximumHealth().doubleValue());
|
||||
double publish = Math.min(maximum, persisted.health().doubleValue());
|
||||
if (!Double.isFinite(maximum) || !Double.isFinite(publish) || publish <= nativeBefore) return;
|
||||
player.setHealth(publish);
|
||||
}
|
||||
|
||||
private void remove(UUID owner) {
|
||||
states.remove(owner); queued.remove(owner); order.remove(owner);
|
||||
}
|
||||
|
||||
private void report(UUID character, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, failure)); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics cannot authorize a retry or native heal. */ }
|
||||
}
|
||||
|
||||
@Override public synchronized void close() {
|
||||
closed = true;
|
||||
if (task != null) task.cancel();
|
||||
states.clear(); order.clear(); queued.clear();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,15 @@ party-max-size: 6
|
||||
sheet-resource-pack-url: ''
|
||||
sheet-resource-pack-sha1: ''
|
||||
|
||||
# Active-session recovery after actual accepted damage has stopped.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
out-of-combat-healing-delay-seconds: 30
|
||||
out-of-combat-healing-interval-seconds: 10
|
||||
out-of-combat-healing-amount: 1
|
||||
quicker-regeneration-i-bonus-healing: 1
|
||||
quicker-regeneration-ii-bonus-healing: 2
|
||||
health-steal-healing-per-kill: 1
|
||||
|
||||
# New-character and transferable fantasy progression defaults.
|
||||
starting-points: 40
|
||||
xp-per-level: 100
|
||||
@@ -89,3 +98,75 @@ inspire-base-temporary-health: 4
|
||||
inspire-cooldown-seconds: 15
|
||||
inspire-range-blocks: 12
|
||||
inspire-duration-seconds: 6
|
||||
|
||||
# Mending Light defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
mending-light-base-healing: 6
|
||||
mending-light-cooldown-seconds: 8
|
||||
mending-light-range-blocks: 12
|
||||
|
||||
# Purify defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
purify-cooldown-seconds: 15
|
||||
purify-range-blocks: 12
|
||||
|
||||
# Guardian Bond defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
guardian-bond-cooldown-seconds: 20
|
||||
guardian-bond-range-blocks: 10
|
||||
guardian-bond-duration-seconds: 6
|
||||
guardian-bond-redirect-percent: 30
|
||||
|
||||
# Rallying Cry defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
rallying-cry-cooldown-seconds: 25
|
||||
rallying-cry-radius-blocks: 8
|
||||
rallying-cry-duration-seconds: 6
|
||||
rallying-cry-speed-amplifier: 0
|
||||
rallying-cry-damage-bonus-percent: 20
|
||||
|
||||
# Frost Nova defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
frost-nova-base-damage: 5
|
||||
frost-nova-cooldown-seconds: 12
|
||||
frost-nova-radius-blocks: 5
|
||||
frost-nova-slowness-amplifier: 1
|
||||
frost-nova-slowness-duration-seconds: 3
|
||||
|
||||
# Chain Lightning defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
chain-lightning-base-damage: 8
|
||||
chain-lightning-cooldown-seconds: 18
|
||||
chain-lightning-initial-range-blocks: 16
|
||||
chain-lightning-jump-range-blocks: 6
|
||||
chain-lightning-additional-targets: 3
|
||||
chain-lightning-jump-multiplier: 0.75
|
||||
|
||||
# Sanctuary defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
sanctuary-cooldown-seconds: 45
|
||||
sanctuary-radius-blocks: 6
|
||||
sanctuary-vertical-range-blocks: 3
|
||||
sanctuary-duration-seconds: 6
|
||||
sanctuary-pulse-interval-seconds: 1
|
||||
sanctuary-healing-per-pulse: 2
|
||||
sanctuary-damage-reduction-percent: 20
|
||||
|
||||
# War Banner defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
war-banner-cooldown-seconds: 45
|
||||
war-banner-placement-range-blocks: 12
|
||||
war-banner-radius-blocks: 8
|
||||
war-banner-vertical-range-blocks: 4
|
||||
war-banner-duration-seconds: 10
|
||||
war-banner-damage-bonus-percent: 25
|
||||
war-banner-knockback-resistance-percent: 50
|
||||
|
||||
# Meteor defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
meteor-base-damage: 14
|
||||
meteor-cooldown-seconds: 45
|
||||
meteor-target-range-blocks: 24
|
||||
meteor-warning-seconds: 2
|
||||
meteor-radius-blocks: 5
|
||||
meteor-vertical-range-blocks: 4
|
||||
@@ -146,8 +146,8 @@ class AbilityEngineNativeTest {
|
||||
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);
|
||||
assertFalse(compactAvailable.contains("Icon:"), available);
|
||||
assertFalse(compactAvailable.contains("Cost:"), available);
|
||||
assertTrue(compactAvailable.contains("Cooldown:4s"), available);
|
||||
var learn = PartyBookNativeTest.button(a, "[Learn]");
|
||||
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
|
||||
@@ -157,10 +157,7 @@ class AbilityEngineNativeTest {
|
||||
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());
|
||||
assertEquals("test_focus", learned.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());
|
||||
@@ -173,9 +170,17 @@ class AbilityEngineNativeTest {
|
||||
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()));
|
||||
var cooling = a.core.inventory.getItem(2);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, cooling.getType());
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
cooling.getItemMeta().getItemModel());
|
||||
assertEquals(net.kyori.adventure.text.Component.text("Test Focus — Cooling Down"), cooling.getItemMeta().displayName());
|
||||
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry).ability(cooling).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));
|
||||
var repeated = new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
|
||||
cooling, null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(repeated);
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, repeated.useItemInHand(), "The pane remains a contained control");
|
||||
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")));
|
||||
@@ -185,6 +190,9 @@ class AbilityEngineNativeTest {
|
||||
PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, a.core.inventory.getItem(2).getType());
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
a.core.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
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.ability.*;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
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.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ChainLightningNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void nativeRuntime() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void characterBookPurchaseAssignmentAndEquippedControlExecuteNativeChain() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "chain-lightning-cooldown-seconds: 0\n");
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true); var caster = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, false);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("ChainLightning")); assertTrue(text.contains("INT20")); assertTrue(text.contains("DEX12"));
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "chain_lightning")); drain(fixture, 2);
|
||||
var first = mock(LivingEntity.class); var second = mock(LivingEntity.class);
|
||||
locate(first, caster.world, UUID.randomUUID(), 10); locate(second, caster.world, UUID.randomUUID(), 14);
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
|
||||
doReturn(new Location(caster.world, 0, 65, 0)).when(caster.player).getEyeLocation();
|
||||
doReturn(true).when(caster.player).hasLineOfSight(first);
|
||||
doReturn(new org.bukkit.util.RayTraceResult(new org.bukkit.util.Vector(10, 64, 0), first))
|
||||
.when(caster.world).rayTraceEntities(any(Location.class), any(org.bukkit.util.Vector.class), eq(16.0),
|
||||
org.mockito.ArgumentMatchers.<java.util.function.Predicate<org.bukkit.entity.Entity>>any());
|
||||
doReturn(List.of(second)).when(first).getNearbyEntities(anyDouble(), anyDouble(), anyDouble()); doReturn(true).when(first).hasLineOfSight(second);
|
||||
var damage = new LinkedHashMap<UUID, Double>(); nativeDamage(fixture, first, damage); nativeDamage(fixture, second, damage);
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertFalse(damage.isEmpty(), caster.messages.toString());
|
||||
assertEquals(13, damage.get(first.getUniqueId()), 1.0e-10); assertEquals(9.75, damage.get(second.getUniqueId()), 1.0e-10);
|
||||
var stored = ((games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"))
|
||||
.find(caster.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertTrue(stored.learned().contains("chain_lightning"));
|
||||
assertEquals("chain_lightning", stored.loadout().getFirst()); assertNotNull(stored.cooldowns().get("chain_lightning"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void exactChainUsesNearestUuidTiesFourTargetLimitDecayAndCancellationContinuation() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); UUID casterId = UUID.randomUUID(); when(caster.getUniqueId()).thenReturn(casterId);
|
||||
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
|
||||
var targets = new ArrayList<LivingEntity>();
|
||||
for (int i = 0; i < 5; i++) { var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(new UUID(0, i + 1));
|
||||
when(target.getLocation()).thenReturn(new Location(world, 4 + i * 2, 64, 0)); when(target.hasLineOfSight(any(org.bukkit.entity.Entity.class))).thenReturn(true); targets.add(target); }
|
||||
when(caster.hasLineOfSight(targets.getFirst())).thenReturn(true);
|
||||
when(targets.get(0).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(2), targets.get(1), targets.get(1)));
|
||||
when(targets.get(1).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(3), targets.get(2)));
|
||||
when(targets.get(2).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(4), targets.get(3)));
|
||||
when(targets.get(3).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(4)));
|
||||
var active = new EquipmentSessions.Active(casterId, UUID.randomUUID(), UUID.randomUUID());
|
||||
var execution = execution(caster, active, targets.getFirst()); var chain = new ChainLightning(ChainLightning.Settings.defaults(), sessions, parties);
|
||||
when(execution.definition()).thenReturn(chain.definition());
|
||||
var attempted = new LinkedHashMap<UUID, Double>();
|
||||
for (int i = 0; i < targets.size(); i++) { final int index = i; var target = targets.get(i);
|
||||
doAnswer(call -> { double amount = call.getArgument(0); attempted.put(target.getUniqueId(), amount);
|
||||
if (index != 1) chain.acceptedDamage(new EntityDamageEvent(target, EntityDamageEvent.DamageCause.LIGHTNING,
|
||||
(DamageSource) call.getArgument(1), amount)); return null;
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class)); }
|
||||
chain.activate(execution);
|
||||
assertEquals(List.of(targets.get(0).getUniqueId(), targets.get(1).getUniqueId(), targets.get(2).getUniqueId(), targets.get(3).getUniqueId()),
|
||||
List.copyOf(attempted.keySet()));
|
||||
assertArrayEquals(new double[]{13, 9.75, 7.3125, 5.484375}, attempted.values().stream().mapToDouble(Double::doubleValue).toArray(), 1.0e-10);
|
||||
verify(targets.get(4), never()).damage(anyDouble(), any(DamageSource.class));
|
||||
verify(execution, times(3)).report(eq(AbilityReport.Type.DAMAGE), any(), any());
|
||||
}
|
||||
|
||||
@Test void boundsVisibilityExclusionsAndEligibilityRaceStopInvalidStrikes() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); when(caster.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
|
||||
var initial = mock(LivingEntity.class); locate(initial, world, UUID.randomUUID(), 16); when(caster.hasLineOfSight(initial)).thenReturn(true);
|
||||
var blocked = mock(LivingEntity.class); locate(blocked, world, UUID.randomUUID(), 18); when(initial.hasLineOfSight(blocked)).thenReturn(false);
|
||||
var outside = mock(LivingEntity.class); locate(outside, world, UUID.randomUUID(), 22.01); when(initial.hasLineOfSight(outside)).thenReturn(true);
|
||||
var ordinary = mock(Player.class); locate(ordinary, world, UUID.randomUUID(), 18); when(initial.hasLineOfSight(ordinary)).thenReturn(true);
|
||||
when(initial.getNearbyEntities(6, 6, 6)).thenReturn(List.of(blocked, outside, ordinary));
|
||||
var active = new EquipmentSessions.Active(caster.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
|
||||
var execution = execution(caster, active, initial); var chain = new ChainLightning(ChainLightning.Settings.defaults(), sessions, parties);
|
||||
when(execution.definition()).thenReturn(chain.definition()); chain.activate(execution);
|
||||
verify(initial).damage(anyDouble(), any(DamageSource.class)); verify(blocked, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
verify(outside, never()).damage(anyDouble(), any(DamageSource.class)); verify(ordinary, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
when(execution.stillCurrent()).thenReturn(false); chain.activate(execution); verify(initial, times(1)).damage(anyDouble(), any(DamageSource.class));
|
||||
}
|
||||
|
||||
@Test void jumpInspectionStopsAtSharedCandidateCeiling() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); when(caster.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
|
||||
var initial = mock(LivingEntity.class); locate(initial, world, UUID.randomUUID(), 10); when(caster.hasLineOfSight(initial)).thenReturn(true);
|
||||
when(initial.hasLineOfSight(any(org.bukkit.entity.Entity.class))).thenReturn(true);
|
||||
var candidates = new ArrayList<org.bukkit.entity.Entity>(); LivingEntity beyond = null;
|
||||
for (int index = 0; index < 257; index++) {
|
||||
var target = mock(LivingEntity.class); locate(target, world, new UUID(1, index + 1), index == 256 ? 10.1 : 15);
|
||||
candidates.add(target); if (index == 256) beyond = target;
|
||||
}
|
||||
when(initial.getNearbyEntities(6, 6, 6)).thenReturn(candidates);
|
||||
var active = new EquipmentSessions.Active(caster.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
|
||||
var execution = execution(caster, active, initial); var chain = new ChainLightning(ChainLightning.Settings.defaults(), sessions, parties);
|
||||
when(execution.definition()).thenReturn(chain.definition()); chain.activate(execution);
|
||||
verify(initial).damage(anyDouble(), any(DamageSource.class)); verify(beyond, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
}
|
||||
|
||||
@Test void definitionFormulaAndArbitraryPrecisionAreExact() {
|
||||
var chain = new ChainLightning(ChainLightning.Settings.defaults(), mock(EquipmentSessions.class), mock(PartyRoster.class));
|
||||
var definition = chain.definition(); assertEquals("chain_lightning", definition.id()); assertEquals(org.bukkit.Material.LIGHTNING_ROD, definition.icon());
|
||||
assertEquals(java.time.Duration.ofSeconds(18), definition.cooldown()); assertEquals(BigInteger.ONE, definition.pointCost());
|
||||
assertEquals(BigInteger.valueOf(20), definition.requirements().get(Stat.INT)); assertEquals(BigInteger.valueOf(12), definition.requirements().get(Stat.DEX));
|
||||
assertEquals(BigInteger.valueOf(13), ChainLightning.initialDamage(BigInteger.valueOf(20)));
|
||||
assertEquals(BigInteger.valueOf(7), ChainLightning.initialDamage(BigInteger.valueOf(9)));
|
||||
var huge = BigInteger.TEN.pow(500); assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(8)), ChainLightning.initialDamage(huge));
|
||||
}
|
||||
|
||||
@Test void pluginLoadsEverySettingAndAdminPublishesOnlyAfterDurableSave() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "chain-lightning-base-damage: 9\nchain-lightning-cooldown-seconds: 4\n"
|
||||
+ "chain-lightning-initial-range-blocks: 15\nchain-lightning-jump-range-blocks: 5\nchain-lightning-additional-targets: 2\nchain-lightning-jump-multiplier: 0.5\n");
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true); var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var definition = registry.find("chain_lightning").orElseThrow(); assertEquals(new BigDecimal("9"), definition.parameters().get("base-damage"));
|
||||
assertEquals(java.time.Duration.ofSeconds(4), definition.cooldown());
|
||||
assertEquals(0, new BigDecimal("15").compareTo(definition.parameters().get("initial-range")));
|
||||
assertEquals(0, new BigDecimal("5").compareTo(definition.parameters().get("jump-range")));
|
||||
assertEquals(new BigDecimal("2"), definition.parameters().get("additional-targets"));
|
||||
assertEquals(new BigDecimal("0.5"), definition.parameters().get("jump-multiplier"));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "chain-lightning-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("chain-lightning-additional-targets", "chain-lightning-base-damage", "chain-lightning-cooldown-seconds",
|
||||
"chain-lightning-initial-range-blocks", "chain-lightning-jump-multiplier", "chain-lightning-jump-range-blocks"),
|
||||
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "chain-lightning-"}));
|
||||
assertEquals(List.of("5", "6"), fixture.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "chain-lightning-jump-range-blocks", ""}));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks"});
|
||||
assertTrue(sender.messages.getLast().contains("=5"));
|
||||
for (String[] update : List.of(new String[]{"chain-lightning-base-damage", "10"},
|
||||
new String[]{"chain-lightning-cooldown-seconds", "3"}, new String[]{"chain-lightning-initial-range-blocks", "14"},
|
||||
new String[]{"chain-lightning-jump-range-blocks", "7"}, new String[]{"chain-lightning-additional-targets", "1"},
|
||||
new String[]{"chain-lightning-jump-multiplier", "0.25"})) {
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", update[0], update[1]});
|
||||
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
}
|
||||
var configured = registry.find("chain_lightning").orElseThrow(); assertEquals(new BigDecimal("10"), configured.parameters().get("base-damage"));
|
||||
assertEquals(java.time.Duration.ofSeconds(3), configured.cooldown());
|
||||
assertEquals(0, new BigDecimal("14").compareTo(configured.parameters().get("initial-range")));
|
||||
assertEquals(0, new BigDecimal("7").compareTo(configured.parameters().get("jump-range")));
|
||||
assertEquals(new BigDecimal("1"), configured.parameters().get("additional-targets"));
|
||||
assertEquals(new BigDecimal("0.25"), configured.parameters().get("jump-multiplier"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
|
||||
var reloaded = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var saved = reloaded.find("chain_lightning").orElseThrow();
|
||||
assertEquals(0, new BigDecimal("7").compareTo(saved.parameters().get("jump-range")));
|
||||
assertEquals(new BigDecimal("0.25"), saved.parameters().get("jump-multiplier"));
|
||||
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
|
||||
}
|
||||
}
|
||||
|
||||
private static void nativeDamage(FantasyStickLifecycleNativeTest.Fixture fixture, LivingEntity target, Map<UUID, Double> damage) {
|
||||
doAnswer(call -> { double amount = call.getArgument(0); DamageSource source = call.getArgument(1);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.getCausingEntity(), target,
|
||||
EntityDamageEvent.DamageCause.LIGHTNING, source, amount);
|
||||
fixture.dispatcher.accept(event); if (!event.isCancelled()) damage.put(target.getUniqueId(), event.getFinalDamage()); return null;
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class));
|
||||
}
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
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));
|
||||
}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
|
||||
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom button(
|
||||
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 = button(page.raw(), label, ability); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + ability);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom button(
|
||||
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 = button(child, label, ability); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
private static void prepareCaster(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
|
||||
stats.put(Stat.INT, BigInteger.valueOf(20)); stats.put(Stat.DEX, 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), old.health(),
|
||||
learned ? Set.of("chain_lightning") : old.learned(), learned ? List.of("chain_lightning", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
|
||||
private static AbilityExecution execution(Player source, EquipmentSessions.Active active, LivingEntity target) {
|
||||
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
|
||||
when(input.player()).thenReturn(source); when(input.session()).thenReturn(active); when(input.target()).thenReturn(target);
|
||||
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
|
||||
var record = new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new EnumMap<>(record.stats()); stats.put(Stat.INT, BigInteger.valueOf(20));
|
||||
when(execution.character()).thenReturn(new CharacterRecord(active.character(), record.name(), record.revision(), stats,
|
||||
record.level(), record.experience(), record.unspentPoints(), record.health(), Set.of("chain_lightning"),
|
||||
List.of("chain_lightning", "", "", "", "", "", ""), record.cooldowns(), record.restrictedKills(), false, null, null));
|
||||
return execution;
|
||||
}
|
||||
private static void locate(LivingEntity entity, org.bukkit.World world, UUID id, double x) {
|
||||
when(entity.getUniqueId()).thenReturn(id); when(entity.getLocation()).thenReturn(new Location(world, x, 64, 0));
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ class CharacterBookNativeTest {
|
||||
assertEquals(1, a.books.size());
|
||||
String rendered = text(a.books.getFirst()).replace("\n", "");
|
||||
for (String expected : java.util.List.of("Level: 30", "XP: MAX", "Unspent points: 9", "STR: " + strength,
|
||||
"revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
|
||||
"Revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
|
||||
assertEquals(saved, characters(f).find(old.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,7 @@ class CharacterBookNativeTest {
|
||||
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
|
||||
stats.putAll(old.stats()); stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
|
||||
stats, old.level(), old.experience(), java.math.BigInteger.ZERO, old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
|
||||
byte[] bytes = Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml"));
|
||||
click(f, a); drainReads(f); runQueued(f);
|
||||
@@ -307,6 +307,123 @@ class CharacterBookNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void linkedBookOrdersOverviewActiveIndexAndConciseAbilityDetailsWithAutomaticAssignment() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(a.core.record.id()).orElseThrow();
|
||||
var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.INT, java.math.BigInteger.valueOf(12));
|
||||
var eligible = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null);
|
||||
repository.replace(eligible, old.revision());
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
|
||||
var pages = nativePages(a);
|
||||
assertTrue(pages.size() <= 100, "Keep each native linked-book view bounded");
|
||||
assertTrue(pages.get(0).getString().contains("STR: 0 [+1 STR]"));
|
||||
assertTrue(pages.get(0).getString().contains("INT: 12 [+1 INT]"));
|
||||
assertTrue(pages.get(1).getString().startsWith("Active abilities"));
|
||||
assertTrue(pages.get(1).getString().contains("3: No Ability"));
|
||||
int index = firstPageStarting(pages, "Ability index");
|
||||
assertTrue(index > 1);
|
||||
var indexedNames = new java.util.ArrayList<String>();
|
||||
int partyPage = firstPageStarting(pages, "Party");
|
||||
for (int page = index; page < pages.size() && pages.get(page).getString().startsWith("Ability index"); page++) {
|
||||
for (String line : pages.get(page).getString().split("\\n")) if (!line.isBlank() && !line.equals("Ability index")) {
|
||||
indexedNames.add(line);
|
||||
var jump = changePage(pages.get(page), line);
|
||||
assertNotNull(jump, line);
|
||||
assertTrue(jump.page() - 1 < partyPage, "Ability details must precede party controls");
|
||||
}
|
||||
}
|
||||
var sortedNames = indexedNames.stream().sorted().toList();
|
||||
assertEquals(sortedNames, indexedNames);
|
||||
assertEquals(20, indexedNames.size());
|
||||
var fireballJump = changePage(pages.get(index), "Fireball");
|
||||
assertNotNull(fireballJump);
|
||||
var detail = pages.get(fireballJump.page() - 1);
|
||||
assertTrue(detail.getString().startsWith("Fireball\nINT 12\n[Learn]"), detail.getString());
|
||||
assertFalse(text(a.books.getLast()).contains("Cost:"));
|
||||
assertFalse(text(a.books.getLast()).contains("Requires:"));
|
||||
assertTrue(text(a.books.getLast()).contains("Shield Bash\nSTR 12, CON 12"));
|
||||
assertFalse(detail.getString().contains("Icon:"));
|
||||
assertFalse(text(a.books.getLast()).contains("Assign ability"));
|
||||
assertFalse(text(a.books.getLast()).contains("[3]"));
|
||||
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals("fireball", saved.loadout().get(0));
|
||||
var refreshed = nativePages(a);
|
||||
assertTrue(refreshed.get(1).getString().contains("3: Fireball"));
|
||||
var activeJump = changePage(refreshed.get(1), "Fireball");
|
||||
assertNotNull(activeJump);
|
||||
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nINT 12\n[Unlearn]"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unlearnAndRelearnReordersIntoLowestOpenSlotsWhileFullLoadoutsRefuse() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(a.core.record.id()).orElseThrow();
|
||||
var stats = new java.util.EnumMap<>(old.stats());
|
||||
for (var stat : games.dmg.fantasystick.progression.Stat.values()) stats.put(stat, java.math.BigInteger.valueOf(100));
|
||||
var prepared = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), java.math.BigInteger.TEN, old.health(),
|
||||
java.util.Set.of("unknown_saved", "orphan_unknown"), java.util.List.of("unknown_saved", "", "", "", "", "", ""),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null);
|
||||
repository.replace(prepared, old.revision());
|
||||
f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
|
||||
assertTrue(PartyBookNativeTest.text(a).contains("orphan_unknown"));
|
||||
|
||||
mutate(f, a, "[Learn]", "fireball");
|
||||
mutate(f, a, "[Learn]", "frost_shard");
|
||||
mutate(f, a, "[Unlearn]", "fireball");
|
||||
mutate(f, a, "[Unlearn]", "frost_shard");
|
||||
mutate(f, a, "[Learn]", "frost_shard");
|
||||
mutate(f, a, "[Learn]", "fireball");
|
||||
var reordered = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(java.util.List.of("unknown_saved", "frost_shard", "fireball", "", "", "", ""), reordered.loadout());
|
||||
assertTrue(reordered.learned().contains("orphan_unknown"));
|
||||
|
||||
var fullActor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var fullOld = repository.load(fullActor.core.record.id()).orElseThrow();
|
||||
var fullStats = new java.util.EnumMap<>(fullOld.stats());
|
||||
for (var stat : games.dmg.fantasystick.progression.Stat.values()) fullStats.put(stat, java.math.BigInteger.valueOf(100));
|
||||
var ids = java.util.List.of("unknown_0", "unknown_1", "unknown_2", "unknown_3", "unknown_4", "unknown_5", "unknown_6");
|
||||
var full = new games.dmg.fantasystick.persistence.CharacterRecord(fullOld.id(), fullOld.name(), fullOld.revision().add(java.math.BigInteger.ONE),
|
||||
fullStats, fullOld.level(), fullOld.experience(), java.math.BigInteger.TEN, fullOld.health(), java.util.Set.copyOf(ids), ids,
|
||||
fullOld.cooldowns(), fullOld.restrictedKills(), false, null, null);
|
||||
repository.replace(full, fullOld.revision());
|
||||
f.players.put(fullActor.core.id, fullActor.player); f.activate(fullActor); PartyBookNativeTest.open(f, fullActor);
|
||||
assertTrue(PartyBookNativeTest.text(fullActor).contains("Unavailable: slots"));
|
||||
assertNull(abilityButtonOrNull(fullActor, "[Learn]", "fireball"));
|
||||
assertEquals(full, characters(f).find(full.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void overviewHidesEveryStatActionWhenNoPointsRemain() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(a.core.record.id()).orElseThrow();
|
||||
var noPoints = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
old.stats(), old.level(), old.experience(), java.math.BigInteger.ZERO, old.health(), old.learned(), old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null);
|
||||
repository.replace(noPoints, old.revision());
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
assertFalse(nativePages(a).get(0).getString().contains("[+1"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void bookAdapterIgnoresOtherControlsAndNativeItemVetoes() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -321,6 +438,51 @@ class CharacterBookNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void mutate(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) throws Exception {
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButtonOrNull(actor, label, ability));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom abilityButtonOrNull(
|
||||
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
|
||||
for (var page : nativePages(actor)) {
|
||||
var found = abilityButtonOrNull(page, label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom abilityButtonOrNull(
|
||||
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 = abilityButtonOrNull(child, label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static java.util.List<net.minecraft.network.chat.Component> nativePages(FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content);
|
||||
return content.pages().stream().map(page -> page.raw()).toList();
|
||||
}
|
||||
private static int firstPageStarting(java.util.List<net.minecraft.network.chat.Component> pages, String prefix) {
|
||||
for (int index = 0; index < pages.size(); index++) if (pages.get(index).getString().startsWith(prefix)) return index;
|
||||
throw new AssertionError("Missing page " + prefix);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.ChangePage changePage(net.minecraft.network.chat.Component component,
|
||||
String label) {
|
||||
if (component.getString().equals(label)
|
||||
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page) return page;
|
||||
for (var child : component.getSiblings()) {
|
||||
var found = changePage(child, label);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static String text(org.bukkit.inventory.ItemStack book) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(book)
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
|
||||
@@ -1285,10 +1285,10 @@ class EquipmentSessionEventsNativeTest {
|
||||
|
||||
@Test void selectedAbilityUseReachesItsHandlerWithoutVanillaThrowDrinkEquipBlockOrPlacement() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
String[] abilities = {"fireball", "purify", "guardian_bond", "shield_bash", "rallying_cry", "war_banner", "revive"};
|
||||
String[] abilities = {"chain_lightning", "purify", "guardian_bond", "shield_bash", "rallying_cry", "frost_nova", "revive"};
|
||||
f.loadout(abilities);
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var target = port(org.bukkit.entity.Player.class);
|
||||
var target = port(org.bukkit.entity.Player.class); a.targetEntity = target;
|
||||
for (int index = 0; index < abilities.length; index++) {
|
||||
int slot = index + 2; a.inventory.getInventory().setSelectedSlot(slot);
|
||||
var item = a.inventory.getItem(slot);
|
||||
|
||||
@@ -1059,6 +1059,47 @@ class EquipmentSessionsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedCharacterCooldownUsesAnAuthorizedPaneAcrossResumeAndRestoresAtTheExactDeadline() throws Exception {
|
||||
var clock = new MutableClock();
|
||||
try (var f = new Fixture(Duration.ofSeconds(900), clock)) {
|
||||
var actor = f.actor(); f.activate(actor);
|
||||
var active = f.sessions.active(actor.id).orElseThrow();
|
||||
var current = f.sessions.currentRecord(active).orElseThrow();
|
||||
var learnedMutation = f.characters.unlockAbility(current.id(), current.revision(), "fireball", java.util.Map.of(), java.math.BigInteger.ONE)
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
var learned = learnedMutation.record().orElseThrow();
|
||||
assertTrue(f.sessions.acknowledgeProgress(active, current.revision(), learned));
|
||||
assertEquals(Material.SNOWBALL, actor.inventory.getItem(2).getType());
|
||||
|
||||
var deadline = clock.now.plusSeconds(30);
|
||||
var cooldownMutation = f.characters.commitAbilityCooldown(learned.id(), learned.revision(), "fireball", deadline)
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
var cooled = cooldownMutation.record().orElseThrow();
|
||||
assertTrue(f.sessions.acknowledgeProgress(active, learned.revision(), cooled));
|
||||
var pane = actor.inventory.getItem(2).clone();
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, pane.getType());
|
||||
assertEquals(SessionControls.COOLDOWN_MODEL, pane.getItemMeta().getItemModel(),
|
||||
"Only the acknowledged persisted cooldown control selects the cosmetic model");
|
||||
assertFalse(f.sessions.acknowledgeProgress(active, current.revision(), learned),
|
||||
"A stale pre-cooldown acknowledgment cannot replace the current presentation");
|
||||
assertEquals(SessionControls.COOLDOWN_MODEL,
|
||||
actor.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
|
||||
f.sessions.exit(actor.player); f.state(actor); f.activate(actor);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, actor.inventory.getItem(2).getType(),
|
||||
"Resume reconstructs the future persisted deadline");
|
||||
assertEquals(SessionControls.COOLDOWN_MODEL, actor.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
clock.advance(Duration.ofSeconds(30));
|
||||
f.sessions.refreshCooldownControls(1);
|
||||
assertEquals(Material.SNOWBALL, actor.inventory.getItem(2).getType());
|
||||
assertNull(actor.inventory.getItem(2).getItemMeta().getItemModel(),
|
||||
"Exact-deadline restoration replaces the whole control without stale cosmetic metadata");
|
||||
assertNotEquals(pane, actor.inventory.getItem(2), "The retired pane is no longer installed");
|
||||
assertEquals(deadline, f.sessions.currentRecord(f.sessions.active(actor.id).orElseThrow()).orElseThrow()
|
||||
.cooldowns().get("fireball"), "Visual restoration does not rewrite the deadline");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sameIdFailedEquipmentPreflightPreservesExistingDeadlineAndAllItems() throws Exception {
|
||||
for (boolean full : new boolean[]{false, true}) {
|
||||
var clock = new MutableClock();
|
||||
@@ -1688,6 +1729,8 @@ class EquipmentSessionsNativeTest {
|
||||
Runnable beforeHealth = () -> {};
|
||||
Runnable healthMutation = () -> {};
|
||||
float attackStrength = 1;
|
||||
boolean sneaking;
|
||||
org.bukkit.entity.Entity targetEntity;
|
||||
ItemStack activeItem = new ItemStack(Material.AIR);
|
||||
int useStarts;
|
||||
int useClears;
|
||||
@@ -1719,10 +1762,14 @@ class EquipmentSessionsNativeTest {
|
||||
}
|
||||
case "getOpenInventory" -> view;
|
||||
case "getLocation" -> location.clone();
|
||||
case "getBoundingBox" -> new org.bukkit.util.BoundingBox(location.getX() - 0.3, location.getY(),
|
||||
location.getZ() - 0.3, location.getX() + 0.3, location.getY() + 1.8, location.getZ() + 0.3);
|
||||
case "getGameMode" -> gameMode;
|
||||
case "setGameMode" -> { beforeMode.run(); gameMode = (org.bukkit.GameMode) args[0]; yield null; }
|
||||
case "getHealth" -> health;
|
||||
case "getAttackCooldown" -> attackStrength;
|
||||
case "isSneaking" -> sneaking;
|
||||
case "getTargetEntity" -> targetEntity;
|
||||
case "getActiveItem" -> activeItem.clone();
|
||||
case "hasActiveItem" -> !activeItem.isEmpty();
|
||||
case "getActiveItemHand" -> org.bukkit.inventory.EquipmentSlot.HAND;
|
||||
|
||||
@@ -60,7 +60,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
|
||||
assertNotNull(fixture.packetListener);
|
||||
assertEquals(5, fixture.timers.size(), "Presentation, ability cleanup, provenance, reward retry and session particles");
|
||||
assertEquals(11, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects and session particles");
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
|
||||
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
|
||||
@@ -73,6 +74,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("BasicAttack") < recipe);
|
||||
assertTrue(fixture.order.indexOf("NutritionPolicy") >= 0);
|
||||
assertTrue(fixture.order.indexOf("NutritionPolicy") < recipe);
|
||||
assertTrue(fixture.order.indexOf("OutOfCombatRecovery") >= 0);
|
||||
assertTrue(fixture.order.indexOf("OutOfCombatRecovery") < recipe);
|
||||
assertTrue(fixture.order.indexOf("RecipeDiscovery") > recipe,
|
||||
"Discovery must not become active before successful recipe registration");
|
||||
assertNotNull(fixture.command);
|
||||
@@ -104,7 +107,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertNotNull(content);
|
||||
String text = content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
|
||||
for (String expected : List.of("Current saved name", "STR: 0", "DEX: 0", "CON: 0", "INT: 0", "WIS: 0", "CHR: 0",
|
||||
"Level: 1", "XP: 0/100", "Unspent points: 40", "Health: 10/10", "Abilities: none")) {
|
||||
"Level: 1", "XP: 0/100", "Unspent points: 40", "Health: 10/10", "Active abilities", "3: No Ability")) {
|
||||
assertTrue(text.contains(expected), expected + " missing from " + text);
|
||||
}
|
||||
assertArrayEquals(original, a.core.snapshot(), "Opening a virtual book never replaces or grants an inventory item");
|
||||
@@ -1320,8 +1323,12 @@ class FantasyStickLifecycleNativeTest {
|
||||
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
|
||||
boolean healthScaled;
|
||||
double healthScale = 20;
|
||||
double absorption;
|
||||
Runnable scaleMutation = () -> {};
|
||||
Runnable foodMutation = () -> {};
|
||||
java.util.function.BiConsumer<Double, org.bukkit.entity.Entity> damage = (amount, source) -> {
|
||||
throw new AssertionError("Native damage port not supplied");
|
||||
};
|
||||
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);
|
||||
@@ -1354,6 +1361,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
yield new org.bukkit.craftbukkit.attribute.CraftAttributeInstance(maximumHealth, org.bukkit.attribute.Attribute.MAX_HEALTH);
|
||||
}
|
||||
case "getMaxHealth" -> maximumHealth.getValue();
|
||||
case "getAbsorptionAmount" -> absorption;
|
||||
case "setAbsorptionAmount" -> { absorption = (Double) args[0]; yield null; }
|
||||
case "getFoodLevel" -> food.getFoodLevel();
|
||||
case "setFoodLevel" -> { food.setFoodLevel((Integer) args[0]); foodMutation.run(); yield null; }
|
||||
case "getSaturation" -> food.getSaturationLevel();
|
||||
@@ -1365,6 +1374,10 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "setHealthScale" -> { healthScale = (Double) args[0]; healthScaled = true; scaleMutation.run(); yield null; }
|
||||
case "setHealthScaled" -> { healthScaled = (Boolean) args[0]; yield null; }
|
||||
case "getGameMode" -> core.gameMode;
|
||||
case "damage" -> {
|
||||
damage.accept((Double) args[0], args.length > 1 ? (org.bukkit.entity.Entity) args[1] : null);
|
||||
yield null;
|
||||
}
|
||||
case "setGameMode" -> {
|
||||
var event = new org.bukkit.event.player.PlayerGameModeChangeEvent((org.bukkit.entity.Player) call.getMock(), (org.bukkit.GameMode) args[0]);
|
||||
modeEvents.accept(event);
|
||||
|
||||
@@ -15,7 +15,7 @@ class FireballNativeTest {
|
||||
@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);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withIntAndHealthSteal(data, a, 12);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
@@ -23,8 +23,6 @@ class FireballNativeTest {
|
||||
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();
|
||||
@@ -56,6 +54,8 @@ class FireballNativeTest {
|
||||
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.getHealth()).thenReturn(1d);
|
||||
org.mockito.Mockito.when(target.getAbsorptionAmount()).thenReturn(0d);
|
||||
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 -> {
|
||||
@@ -72,8 +72,11 @@ class FireballNativeTest {
|
||||
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(),
|
||||
var rewarded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.TEN, rewarded.experience(),
|
||||
"Native projectile attribution reaches the originating character's combat XP");
|
||||
assertEquals(5, a.player.getHealth(), "An actual Fireball final blow activates learned Health Steal");
|
||||
assertEquals(0, java.math.BigDecimal.valueOf(5).compareTo(rewarded.health()));
|
||||
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");
|
||||
@@ -85,11 +88,15 @@ class FireballNativeTest {
|
||||
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()));
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
a.core.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
|
||||
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(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
a.core.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
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());
|
||||
@@ -109,8 +116,6 @@ class FireballNativeTest {
|
||||
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());
|
||||
@@ -144,6 +149,8 @@ class FireballNativeTest {
|
||||
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());
|
||||
assertNull(a.core.inventory.getItem(2).getItemMeta().getItemModel(),
|
||||
"Unlearning replaces the cooling control and leaves no stale invisible model");
|
||||
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 WIS]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
@@ -152,9 +159,10 @@ class FireballNativeTest {
|
||||
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"));
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
a.core.inventory.getItem(2).getItemMeta().getItemModel(),
|
||||
"Relearning reconstructs the retained future cooldown model");
|
||||
cast(f, a); assertEquals(1, launches.get());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
|
||||
@@ -163,6 +171,10 @@ class FireballNativeTest {
|
||||
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(org.bukkit.Material.GRAY_STAINED_GLASS_PANE, b.core.inventory.getItem(2).getType(),
|
||||
"The transferred character reconstructs its retained cooldown pane");
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
b.core.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
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());
|
||||
@@ -291,6 +303,8 @@ class FireballNativeTest {
|
||||
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());
|
||||
org.mockito.Mockito.verify(sessions, org.mockito.Mockito.never()).acknowledgeProgress(
|
||||
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
|
||||
assertEquals(1, failures.size());
|
||||
engine.close(); fireball.close(); parties.close();
|
||||
}
|
||||
@@ -362,6 +376,17 @@ class FireballNativeTest {
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static void withIntAndHealthSteal(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(), java.math.BigDecimal.valueOf(4), old.learned(), old.loadout(),
|
||||
java.util.List.of("health_steal", "", ""), old.cooldowns(), old.restrictedKills(), false, null, null);
|
||||
repository.replace(saved, old.revision());
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
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.ability.AbilityExecution;
|
||||
import games.dmg.fantasystick.ability.FrostNova;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
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.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
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.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class FrostNovaNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedBurstDamagesSlowsEligibleTargetsOnceAndRetainsCooldown() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
assertTrue(java.util.Arrays.stream(EntityDamageEvent.getHandlerList().getRegisteredListeners())
|
||||
.anyMatch(listener -> listener.getListener() instanceof FrostNova));
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data); var opponent = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareCaster(data, caster, false); fixture.players.put(caster.core.id, caster.player);
|
||||
fixture.players.put(ally.core.id, ally.player); fixture.players.put(opponent.core.id, opponent.player);
|
||||
fixture.activate(caster); fixture.activate(ally); fixture.activate(opponent); party(fixture, caster, ally);
|
||||
var enemy = mock(LivingEntity.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getLocation()).thenReturn(new Location(caster.world, 3, 64, 0)); when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
var vetoed = mock(LivingEntity.class); when(vetoed.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(vetoed.getLocation()).thenReturn(new Location(caster.world, 2.5, 64, 0));
|
||||
doReturn(true).when(caster.player).hasLineOfSight(enemy); doReturn(true).when(caster.player).hasLineOfSight(vetoed);
|
||||
locate(caster, ally.player, 2, true); locate(caster, opponent.player, 4, true);
|
||||
var ordinary = mock(Player.class); when(ordinary.getUniqueId()).thenReturn(UUID.randomUUID()); locate(caster, ordinary, 2, true);
|
||||
doReturn(List.of(enemy, enemy, vetoed, ally.player, ordinary, opponent.player)).when(caster.player)
|
||||
.getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
|
||||
var enemyEffects = effects(enemy); var vetoedEffects = effects(vetoed);
|
||||
var opponentEffects = effects(opponent.player); effects(ally.player); effects(ordinary);
|
||||
var damage = new HashMap<UUID, Double>(); damage(fixture, enemy, damage); damage(fixture, vetoed, damage); damage(fixture, opponent.player, damage);
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.LOWEST)
|
||||
public void cancel(EntityDamageEvent event) { if (event.getEntity() == vetoed) event.setCancelled(true); }
|
||||
}
|
||||
fixture.dispatcher.register(new Veto(), fixture.plugin);
|
||||
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("FrostNova")); assertTrue(text.contains("INT16")); assertTrue(text.contains("DEX10"));
|
||||
assertFalse(text.contains("Cost:")); assertTrue(text.contains("5")); assertTrue(text.contains("SlownessII"));
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "frost_nova")); drain(fixture, 2);
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertEquals(8, damage.get(enemy.getUniqueId()), 1.0e-9); assertEquals(8, damage.get(opponent.core.id), 1.0e-9);
|
||||
assertEquals(2, damage.size(), "Duplicate, cancelled, party and ordinary targets cannot add accepted hits");
|
||||
assertFalse(vetoedEffects.containsKey(PotionEffectType.SLOWNESS));
|
||||
assertEquals(1, enemyEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
|
||||
assertEquals(1, opponentEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
|
||||
var stored = storage(fixture).find(caster.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertNotNull(stored.cooldowns().get("frost_nova"));
|
||||
assertEquals(60L, fixture.delayedTicks.getLast()); fixture.delayed.getLast().run();
|
||||
assertFalse(enemyEffects.containsKey(PotionEffectType.SLOWNESS)); assertFalse(opponentEffects.containsKey(PotionEffectType.SLOWNESS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void spherePathOrderingCandidateAndTargetBoundsAreDeterministic() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var source = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); when(source.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(source.getEyeLocation()).thenReturn(new Location(world, 0, 64, 0)); when(source.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
var active = new EquipmentSessions.Active(source.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(source.getUniqueId())).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
|
||||
var candidates = new ArrayList<Entity>(); var hitOrder = new ArrayList<UUID>();
|
||||
for (int index = 0; index < 270; index++) {
|
||||
var target = mock(LivingEntity.class); var id = new UUID(0, 300 - index); when(target.getUniqueId()).thenReturn(id);
|
||||
double distance = index < 70 ? 1 + index / 100.0 : 4;
|
||||
when(target.getLocation()).thenReturn(new Location(world, distance, 64, 0)); when(source.hasLineOfSight(target)).thenReturn(index != 1);
|
||||
doAnswer(call -> { hitOrder.add(id); return null; }).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
effects(target); candidates.add(target);
|
||||
}
|
||||
when(source.getNearbyEntities(5, 5, 5)).thenReturn(candidates);
|
||||
var execution = execution(source, active); var nova = new FrostNova(FrostNova.Settings.defaults(), sessions, parties);
|
||||
when(execution.definition()).thenReturn(nova.definition()); nova.activate(execution);
|
||||
assertEquals(64, hitOrder.size()); assertFalse(hitOrder.contains(new UUID(0, 299)), "Obstructed target excluded");
|
||||
var sorted = new ArrayList<>(hitOrder); sorted.sort(UUID::compareTo);
|
||||
assertNotEquals(sorted, hitOrder, "Distance precedes UUID while UUID breaks equal-distance ties");
|
||||
verify((LivingEntity) candidates.getLast(), never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
verify(execution, atMost(64)).report(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test void exactSphereBoundaryIsIncludedWhileOutsideAndObstructedTargetsAreExcluded() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var source = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); when(source.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(source.getEyeLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
var active = new EquipmentSessions.Active(source.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(source.getUniqueId())).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
|
||||
var boundary = mock(LivingEntity.class); var outside = mock(LivingEntity.class); var blocked = mock(LivingEntity.class);
|
||||
for (var target : List.of(boundary, outside, blocked)) when(target.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(boundary.getLocation()).thenReturn(new Location(world, 5, 64, 0));
|
||||
when(outside.getLocation()).thenReturn(new Location(world, 5.01, 64, 0));
|
||||
when(blocked.getLocation()).thenReturn(new Location(world, 4, 64, 0));
|
||||
when(source.hasLineOfSight(boundary)).thenReturn(true); when(source.hasLineOfSight(outside)).thenReturn(true);
|
||||
when(source.hasLineOfSight(blocked)).thenReturn(true); when(source.getNearbyEntities(5, 5, 5)).thenReturn(List.of(boundary, outside, blocked));
|
||||
when(world.rayTraceBlocks(any(Location.class), any(org.bukkit.util.Vector.class), eq(4.0),
|
||||
eq(org.bukkit.FluidCollisionMode.NEVER), eq(true))).thenReturn(mock(org.bukkit.util.RayTraceResult.class));
|
||||
effects(boundary); effects(outside); effects(blocked);
|
||||
var execution = execution(source, active); var nova = new FrostNova(FrostNova.Settings.defaults(), sessions, parties);
|
||||
when(execution.definition()).thenReturn(nova.definition()); nova.activate(execution);
|
||||
verify(boundary).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
verify(outside, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
verify(blocked, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
}
|
||||
|
||||
@Test void cancelledDamageAndPartyOrModeRaceApplyNoSlownessOrCredit() throws Exception {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var source = mock(Player.class);
|
||||
var target = mock(Player.class); var world = mock(org.bukkit.World.class); UUID sourceId = UUID.randomUUID(), targetId = UUID.randomUUID();
|
||||
when(source.getUniqueId()).thenReturn(sourceId); when(target.getUniqueId()).thenReturn(targetId);
|
||||
when(source.getEyeLocation()).thenReturn(new Location(world, 0, 64, 0)); when(source.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
when(target.getLocation()).thenReturn(new Location(world, 3, 64, 0)); when(source.hasLineOfSight(target)).thenReturn(true);
|
||||
when(source.getNearbyEntities(5, 5, 5)).thenReturn(List.of(target));
|
||||
var sourceActive = new EquipmentSessions.Active(sourceId, UUID.randomUUID(), UUID.randomUUID());
|
||||
var targetActive = new EquipmentSessions.Active(targetId, UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(sourceId)).thenReturn(java.util.Optional.of(sourceActive)); when(sessions.current(sourceActive)).thenReturn(true);
|
||||
when(sessions.active(targetId)).thenReturn(java.util.Optional.of(targetActive)); when(sessions.current(targetActive)).thenReturn(true);
|
||||
var targetEffects = effects(target); var execution = execution(source, sourceActive);
|
||||
var nova = new FrostNova(FrostNova.Settings.defaults(), sessions, parties); when(execution.definition()).thenReturn(nova.definition());
|
||||
doAnswer(call -> { when(parties.partyOf(sourceId)).thenReturn(java.util.Optional.of(
|
||||
new PartyRoster.Party(UUID.randomUUID(), sourceId, List.of(sourceId, targetId)))); return null; })
|
||||
.when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
nova.activate(execution); assertTrue(targetEffects.isEmpty()); verify(execution, never()).report(any(), any(), any());
|
||||
|
||||
when(parties.partyOf(sourceId)).thenReturn(java.util.Optional.empty()); when(sessions.current(targetActive)).thenReturn(false);
|
||||
nova.activate(execution); verify(target, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
}
|
||||
|
||||
@Test void ownedSlownessRestoresWeakerPriorStateButPreservesNewerStrongerState() throws Exception {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var source = mock(Player.class);
|
||||
var target = mock(LivingEntity.class); var world = mock(org.bukkit.World.class);
|
||||
when(source.getUniqueId()).thenReturn(UUID.randomUUID()); when(target.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(source.getEyeLocation()).thenReturn(new Location(world, 0, 64, 0)); when(target.getLocation()).thenReturn(new Location(world, 2, 64, 0));
|
||||
when(source.hasLineOfSight(target)).thenReturn(true); when(source.getNearbyEntities(5, 5, 5)).thenReturn(List.of(target));
|
||||
var active = new EquipmentSessions.Active(source.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(source.getUniqueId())).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
|
||||
var targetEffects = effects(target); targetEffects.put(PotionEffectType.SLOWNESS,
|
||||
new PotionEffect(PotionEffectType.SLOWNESS, 200, 0));
|
||||
var execution = execution(source, active); var nova = new FrostNova(FrostNova.Settings.defaults(), sessions, parties);
|
||||
when(execution.definition()).thenReturn(nova.definition());
|
||||
doAnswer(call -> { var damageSource = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var event = new EntityDamageEvent(target, EntityDamageEvent.DamageCause.FREEZE, damageSource, (Double) call.getArgument(0));
|
||||
nova.acceptedDamage(event); return null; }).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
nova.activate(execution); assertEquals(1, targetEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
|
||||
var resource = org.mockito.ArgumentCaptor.forClass(AutoCloseable.class); verify(execution).own(resource.capture());
|
||||
resource.getValue().close(); assertEquals(0, targetEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
|
||||
|
||||
var execution2 = execution(source, active); when(execution2.definition()).thenReturn(nova.definition());
|
||||
nova.activate(execution2); targetEffects.put(PotionEffectType.SLOWNESS, new PotionEffect(PotionEffectType.SLOWNESS, 400, 3));
|
||||
var resource2 = org.mockito.ArgumentCaptor.forClass(AutoCloseable.class); verify(execution2).own(resource2.capture());
|
||||
resource2.getValue().close(); assertEquals(3, targetEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"frost-nova-base-damage: 7\nfrost-nova-cooldown-seconds: 2\nfrost-nova-radius-blocks: 8\n"
|
||||
+ "frost-nova-slowness-amplifier: 2\nfrost-nova-slowness-duration-seconds: 5\n");
|
||||
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 definition = registry.find("frost_nova").orElseThrow(); assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
|
||||
assertEquals(new BigDecimal("7"), definition.parameters().get("base-damage")); assertEquals(new BigDecimal("8"), definition.parameters().get("radius"));
|
||||
assertEquals(new BigDecimal("2"), definition.parameters().get("slowness-amplifier"));
|
||||
assertEquals(0, new BigDecimal("5").compareTo(definition.parameters().get("slowness-duration-seconds")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "frost-nova-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "10"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("frost-nova-base-damage", "frost-nova-cooldown-seconds", "frost-nova-radius-blocks",
|
||||
"frost-nova-slowness-amplifier", "frost-nova-slowness-duration-seconds"),
|
||||
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "frost-nova-"}));
|
||||
assertEquals(List.of("5", "8"), fixture.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "frost-nova-radius-blocks", ""}));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks"});
|
||||
assertTrue(sender.messages.getLast().contains("=8"));
|
||||
for (String[] update : List.of(new String[]{"frost-nova-base-damage", "9"},
|
||||
new String[]{"frost-nova-cooldown-seconds", "3"}, new String[]{"frost-nova-radius-blocks", "10"},
|
||||
new String[]{"frost-nova-slowness-amplifier", "3"}, new String[]{"frost-nova-slowness-duration-seconds", "6"})) {
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", update[0], update[1]});
|
||||
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
}
|
||||
var configured = registry.find("frost_nova").orElseThrow(); assertEquals(java.time.Duration.ofSeconds(3), configured.cooldown());
|
||||
assertEquals(new BigDecimal("9"), configured.parameters().get("base-damage"));
|
||||
assertEquals(0, new BigDecimal("10").compareTo(configured.parameters().get("radius")));
|
||||
assertEquals(new BigDecimal("3"), configured.parameters().get("slowness-amplifier"));
|
||||
assertEquals(0, new BigDecimal("6").compareTo(configured.parameters().get("slowness-duration-seconds")));
|
||||
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, true);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
|
||||
var enemy = mock(LivingEntity.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getLocation()).thenReturn(new Location(caster.world, 9, 64, 0)); doReturn(true).when(caster.player).hasLineOfSight(enemy);
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getEyeLocation();
|
||||
doReturn(List.of(enemy)).when(caster.player).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
|
||||
var enemyEffects = effects(enemy); var applied = new HashMap<UUID, Double>(); damage(fixture, enemy, applied);
|
||||
use(fixture, caster); drain(fixture, 2); assertEquals(12, applied.get(enemy.getUniqueId()), 1.0e-9);
|
||||
assertEquals(3, enemyEffects.get(PotionEffectType.SLOWNESS).getAmplifier()); assertEquals(120L, fixture.delayedTicks.getLast());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "10", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
|
||||
var saved = ((games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry"))
|
||||
.find("frost_nova").orElseThrow(); assertEquals(new BigDecimal("9"), saved.parameters().get("base-damage"));
|
||||
assertEquals(0, new BigDecimal("10").compareTo(saved.parameters().get("radius")));
|
||||
assertEquals(new BigDecimal("3"), saved.parameters().get("slowness-amplifier"));
|
||||
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
|
||||
assertEquals(0, new BigDecimal("6").compareTo(saved.parameters().get("slowness-duration-seconds")));
|
||||
}
|
||||
for (String invalid : List.of("frost-nova-base-damage: -1\n", "frost-nova-cooldown-seconds: -1\n",
|
||||
"frost-nova-radius-blocks: 0\n", "frost-nova-slowness-amplifier: -1\n", "frost-nova-slowness-duration-seconds: 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 fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true)); assertTrue(fixture.order.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void arbitraryPrecisionFormulaUsesFloorModifier() {
|
||||
assertEquals(BigInteger.valueOf(8), FrostNova.damage(BigInteger.valueOf(16)));
|
||||
assertEquals(BigInteger.valueOf(4), FrostNova.damage(BigInteger.valueOf(9)));
|
||||
var huge = BigInteger.TEN.pow(500); assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(5)), FrostNova.damage(huge));
|
||||
}
|
||||
|
||||
private static AbilityExecution execution(Player source, EquipmentSessions.Active active) {
|
||||
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
|
||||
when(input.player()).thenReturn(source); when(input.session()).thenReturn(active);
|
||||
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
|
||||
var record = new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new EnumMap<>(record.stats()); stats.put(Stat.INT, BigInteger.valueOf(16));
|
||||
when(execution.character()).thenReturn(new CharacterRecord(active.character(), record.name(), record.revision(), stats,
|
||||
record.level(), record.experience(), record.unspentPoints(), record.health(), Set.of("frost_nova"),
|
||||
List.of("frost_nova", "", "", "", "", "", ""), record.cooldowns(), record.restrictedKills(), false, null, null));
|
||||
return execution;
|
||||
}
|
||||
private static Map<org.bukkit.potion.PotionEffectType, PotionEffect> effects(LivingEntity target) {
|
||||
var effects = new HashMap<org.bukkit.potion.PotionEffectType, PotionEffect>();
|
||||
doAnswer(call -> effects.get(call.getArgument(0))).when(target).getPotionEffect(any());
|
||||
doAnswer(call -> { PotionEffect effect = call.getArgument(0); effects.put(effect.getType(), effect); return true; })
|
||||
.when(target).addPotionEffect(any());
|
||||
doAnswer(call -> { effects.remove(call.getArgument(0)); return null; }).when(target).removePotionEffect(any()); return effects;
|
||||
}
|
||||
private static void damage(FantasyStickLifecycleNativeTest.Fixture fixture, LivingEntity target, Map<UUID, Double> damage) {
|
||||
doAnswer(call -> { double amount = call.getArgument(0); org.bukkit.damage.DamageSource source = call.getArgument(1);
|
||||
var event = new EntityDamageByEntityEvent(source.getCausingEntity(), target, EntityDamageEvent.DamageCause.FREEZE, source, amount);
|
||||
fixture.dispatcher.accept(event); if (!event.isCancelled()) damage.merge(target.getUniqueId(), event.getFinalDamage(), Double::sum); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
}
|
||||
private static void locate(FantasyStickLifecycleNativeTest.Actor caster, Player target, double distance, boolean visible) {
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getEyeLocation();
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
|
||||
doReturn(new Location(caster.world, distance, 64, 0)).when(target).getLocation(); doReturn(visible).when(caster.player).hasLineOfSight(target);
|
||||
}
|
||||
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 void party(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor source, FantasyStickLifecycleNativeTest.Actor target) {
|
||||
var parties = PartyLifecycleNativeTest.parties(fixture); var group = parties.create(source.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(target.core.id, parties.invite(source.core.id, group.id(), target.core.id).orElseThrow().id()));
|
||||
}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
|
||||
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
}
|
||||
private static ClickEvent.Custom button(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 = button(page.raw(), label, ability); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + ability);
|
||||
}
|
||||
private static ClickEvent.Custom button(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 = button(child, label, ability); if (found != null) return found; } return null;
|
||||
}
|
||||
private static void prepareCaster(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
|
||||
stats.put(Stat.INT, BigInteger.valueOf(16)); stats.put(Stat.DEX, BigInteger.TEN);
|
||||
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 ? Set.of("frost_nova") : old.learned(), learned ? List.of("frost_nova", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,6 @@ class FrostShardNativeTest {
|
||||
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();
|
||||
@@ -447,8 +445,6 @@ class FrostShardNativeTest {
|
||||
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,
|
||||
|
||||
@@ -30,8 +30,6 @@ class GreatSwordNativeTest {
|
||||
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");
|
||||
@@ -317,8 +315,6 @@ class GreatSwordNativeTest {
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
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.ability.AbilityExecution;
|
||||
import games.dmg.fantasystick.ability.AbilityReport;
|
||||
import games.dmg.fantasystick.ability.GuardianBond;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.GuardianBondSettings;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
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.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
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 GuardianBondNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedUseRedirectsNativeDamageAndPersistsCooldown() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var guardian = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareGuardian(data, guardian, false);
|
||||
fixture.players.put(guardian.core.id, guardian.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(guardian); fixture.activate(ally); party(fixture, guardian, ally); aim(guardian, ally, 8, true);
|
||||
nativeDamage(fixture, guardian);
|
||||
|
||||
guardian.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, guardian);
|
||||
String text = PartyBookNativeTest.text(guardian).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("GuardianBond")); assertTrue(text.contains("CON16")); assertTrue(text.contains("WIS10"));
|
||||
assertFalse(text.contains("Cost:")); assertTrue(text.contains("30%")); assertTrue(text.contains("6seconds"));
|
||||
PartyBookNativeTest.click(fixture, guardian, button(guardian, "[Learn]", "guardian_bond")); drain(fixture, 2);
|
||||
use(fixture, guardian); drain(fixture, 2);
|
||||
|
||||
var enemy = mock(org.bukkit.entity.Zombie.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var hit = hit(enemy, ally.player, 10); fixture.dispatcher.accept(hit);
|
||||
assertEquals(7, hit.getDamage(), 1.0e-9); assertEquals(7, guardian.player.getHealth(), 1.0e-9);
|
||||
var stored = storage(fixture).find(guardian.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertNotNull(stored.cooldowns().get("guardian_bond"));
|
||||
assertEquals(120L, fixture.delayedTicks.getLast()); fixture.delayed.getLast().run();
|
||||
var expired = hit(enemy, ally.player, 10); fixture.dispatcher.accept(expired);
|
||||
assertEquals(10, expired.getDamage(), 1.0e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void lethalRedirectUsesNormalDefeatAndOnlyRemovesGuardianAvailableHealth() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var guardian = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareGuardian(data, guardian, true);
|
||||
fixture.players.put(guardian.core.id, guardian.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(guardian); fixture.activate(ally); party(fixture, guardian, ally); aim(guardian, ally, 8, true);
|
||||
var defeatOrder = new ArrayList<String>();
|
||||
guardian.damage = (amount, source) -> {
|
||||
var redirected = hit(source, guardian.player, amount); fixture.dispatcher.accept(redirected);
|
||||
if (redirected.isCancelled()) return;
|
||||
guardian.core.health = Math.max(0, guardian.core.health - redirected.getFinalDamage());
|
||||
if (guardian.core.health == 0) {
|
||||
var deathSource = 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(guardian.player, deathSource,
|
||||
new ArrayList<>(), 0, net.kyori.adventure.text.Component.text("redirected defeat"), true);
|
||||
fixture.dispatcher.accept(death); assertTrue(death.isCancelled()); defeatOrder.add("guardian");
|
||||
}
|
||||
};
|
||||
use(fixture, guardian); drain(fixture, 2); guardian.core.health = 1; ally.core.health = 9;
|
||||
var hit = hit(mock(org.bukkit.entity.Zombie.class), ally.player, 10); fixture.dispatcher.accept(hit);
|
||||
assertEquals(List.of("guardian"), defeatOrder,
|
||||
"Redirected damage reaches normal guardian defeat before original damage resumes");
|
||||
assertEquals(9, hit.getDamage(), 1.0e-9, "Overkill cannot delete damage the guardian could not accept");
|
||||
ally.core.health = Math.max(0, ally.core.health - hit.getFinalDamage());
|
||||
if (ally.core.health == 0) {
|
||||
var deathSource = 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(ally.player, deathSource,
|
||||
new ArrayList<>(), 0, net.kyori.adventure.text.Component.text("remaining original defeat"), true);
|
||||
fixture.dispatcher.accept(death); assertTrue(death.isCancelled()); defeatOrder.add("ally");
|
||||
}
|
||||
assertEquals(List.of("guardian", "ally"), defeatOrder, "Both characters may be defeated in the approved order");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualProtectionCreatesOnePersistedSupportShareForTheAidedDirectContributor() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var guardian = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareGuardian(data, guardian, true);
|
||||
fixture.players.put(guardian.core.id, guardian.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(guardian); fixture.activate(ally); party(fixture, guardian, ally); aim(guardian, ally, 8, true);
|
||||
nativeDamage(fixture, guardian); use(fixture, guardian); drain(fixture, 2);
|
||||
var enemy = mock(LivingEntity.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(enemy.getLocation()).thenReturn(new Location(guardian.world, 8, 64, 0));
|
||||
when(guardian.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
fixture.dispatcher.accept(hit(enemy, ally.player, 10));
|
||||
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(ally.player).withDirectEntity(ally.player).build();
|
||||
fixture.dispatcher.accept(new EntityDamageByEntityEvent(ally.player, enemy,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 3));
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(guardian.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void conservedRoutingUsesPostRecipientAndPostGuardianMitigationCancellationAndLethalCaps() {
|
||||
var context = new Context(); var requested = new ArrayList<BigDecimal>();
|
||||
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
|
||||
var guardianProtection = mock(AbilityExecution.class); when(guardianProtection.stillCurrent()).thenReturn(true);
|
||||
mitigation.grant(guardianProtection, context.guardian, new BigDecimal("25"));
|
||||
var outgoing = new games.dmg.fantasystick.ability.OutgoingDamageMitigation(
|
||||
context.sessions, context.parties, ignored -> {});
|
||||
var enemyPenalty = mock(AbilityExecution.class); when(enemyPenalty.stillCurrent()).thenReturn(true);
|
||||
outgoing.grant(enemyPenalty, context.enemy, new BigDecimal("20"));
|
||||
context.damage = (guardian, attacker, amount) -> {
|
||||
requested.add(amount); var redirected = hit(attacker, guardian, amount.doubleValue());
|
||||
outgoing.reduce(redirected); mitigation.reduce(redirected); return BigDecimal.valueOf(redirected.getDamage());
|
||||
};
|
||||
context.activate();
|
||||
var accepted = hit(context.enemy, context.ally, 10); outgoing.reduce(accepted); context.bond.redirect(accepted);
|
||||
assertEquals(0, new BigDecimal("2.4").compareTo(requested.getFirst()));
|
||||
assertEquals(6.2, accepted.getDamage(), 1.0e-9);
|
||||
assertEquals(new BigDecimal("1.8"), context.supports.getFirst().amount());
|
||||
verify(context.execution).report(AbilityReport.Type.PREVENTION, context.ally, new BigDecimal("1.8"));
|
||||
|
||||
context.damage = (guardian, attacker, amount) -> {
|
||||
var redirected = hit(attacker, guardian, amount.doubleValue()); redirected.setCancelled(true);
|
||||
mitigation.reduce(redirected); return redirected.isCancelled() ? BigDecimal.ZERO : BigDecimal.valueOf(redirected.getDamage());
|
||||
};
|
||||
var cancelled = hit(context.enemy, context.ally, 8); context.bond.redirect(cancelled);
|
||||
assertEquals(8, cancelled.getDamage(), 1.0e-9); assertEquals(1, context.supports.size());
|
||||
|
||||
context.damage = (guardian, attacker, amount) -> BigDecimal.ONE;
|
||||
var lethal = hit(context.enemy, context.ally, 10); context.bond.redirect(lethal);
|
||||
assertEquals(9, lethal.getDamage(), 1.0e-9, "Only the one point accepted by a lethal guardian is removed");
|
||||
}
|
||||
|
||||
@Test void conflictsCyclesAndInvalidatedRangePartyOrSessionCannotStackOrRedirect() {
|
||||
var context = new Context(); var calls = new java.util.concurrent.atomic.AtomicInteger();
|
||||
context.damage = (guardian, attacker, amount) -> {
|
||||
calls.incrementAndGet();
|
||||
var nested = hit(attacker, guardian, amount.doubleValue()); context.bond.redirect(nested);
|
||||
return BigDecimal.valueOf(nested.getDamage());
|
||||
};
|
||||
context.activate();
|
||||
assertFalse(context.bond.accepts(context.input), "The same caster/target cannot stack another bond");
|
||||
|
||||
var reverse = context.reverse(); reverse.activate();
|
||||
var cycle = hit(context.enemy, context.guardian, 10); context.bond.redirect(cycle);
|
||||
assertEquals(1, calls.get(), "Marked redirected damage must not recurse through the opposite bond");
|
||||
assertEquals(7, cycle.getDamage(), 1.0e-9);
|
||||
|
||||
when(context.ally.getLocation()).thenReturn(new Location(context.world, 11, 64, 0)); context.bond.sweep();
|
||||
when(context.ally.getLocation()).thenReturn(new Location(context.world, 8, 64, 0));
|
||||
var separated = hit(context.enemy, context.ally, 10); context.bond.redirect(separated);
|
||||
assertEquals(10, separated.getDamage(), 1.0e-9); verify(context.execution).finish();
|
||||
|
||||
var disbanded = new Context(); disbanded.activate();
|
||||
when(disbanded.parties.partyOf(disbanded.ally.getUniqueId())).thenReturn(java.util.Optional.empty()); disbanded.bond.sweep();
|
||||
when(disbanded.parties.partyOf(disbanded.ally.getUniqueId())).thenReturn(java.util.Optional.of(disbanded.party));
|
||||
var noParty = hit(disbanded.enemy, disbanded.ally, 10); disbanded.bond.redirect(noParty);
|
||||
assertEquals(10, noParty.getDamage(), 1.0e-9); verify(disbanded.execution).finish();
|
||||
var exited = new Context(); exited.activate(); when(exited.sessions.current(exited.allyActive)).thenReturn(false); exited.bond.sweep();
|
||||
when(exited.sessions.current(exited.allyActive)).thenReturn(true);
|
||||
var noSession = hit(exited.enemy, exited.ally, 10); exited.bond.redirect(noSession);
|
||||
assertEquals(10, noSession.getDamage(), 1.0e-9); verify(exited.execution).finish();
|
||||
}
|
||||
|
||||
@Test void selfOrdinaryNonPartyRangeLineOfSightAndConflictRefuseBeforeCooldown() throws Exception {
|
||||
for (int route = 0; route < 6; route++) {
|
||||
var directory = data.resolve("refusal-" + route);
|
||||
if (route == 5) {
|
||||
java.nio.file.Files.createDirectories(directory);
|
||||
java.nio.file.Files.writeString(directory.resolve("config.yml"),
|
||||
"guardian-bond-cooldown-seconds: 0\nguardian-bond-range-blocks: 10\n"
|
||||
+ "guardian-bond-duration-seconds: 6\nguardian-bond-redirect-percent: 30\n");
|
||||
}
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
fixture.plugin.setEnabled(true); var guardian = new FantasyStickLifecycleNativeTest.Actor(directory);
|
||||
prepareGuardian(directory, guardian, true); fixture.players.put(guardian.core.id, guardian.player); fixture.activate(guardian);
|
||||
Player target;
|
||||
if (route == 0) target = guardian.player;
|
||||
else if (route == 1) { target = mock(Player.class); when(target.getUniqueId()).thenReturn(UUID.randomUUID()); }
|
||||
else {
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(directory);
|
||||
fixture.players.put(ally.core.id, ally.player); fixture.activate(ally); target = ally.player;
|
||||
if (route != 2) party(fixture, guardian, ally);
|
||||
}
|
||||
aim(guardian, target, route == 3 ? 11 : 8, route != 4);
|
||||
if (route == 5) { use(fixture, guardian); drain(fixture, 2); }
|
||||
use(fixture, guardian); drain(fixture, 2);
|
||||
var cooldowns = storage(fixture).find(guardian.core.record.id()).get().orElseThrow().cooldowns();
|
||||
if (route < 5) assertFalse(cooldowns.containsKey("guardian_bond"), "route " + route);
|
||||
else assertEquals(1, fixture.delayed.size(), "A conflicting zero-cooldown cast must not stack a second bond");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configurationAdminRestartAndFutureBondCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"guardian-bond-cooldown-seconds: 2\nguardian-bond-range-blocks: 14\n"
|
||||
+ "guardian-bond-duration-seconds: 8\nguardian-bond-redirect-percent: 40\n");
|
||||
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 definition = registry.find("guardian_bond").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
|
||||
assertEquals(new BigDecimal("14"), definition.parameters().get("range"));
|
||||
assertEquals(0, new BigDecimal("8").compareTo(definition.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("40"), definition.parameters().get("redirect-percent"));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "guardian-bond-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-range-blocks", "16"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("guardian-bond-cooldown-seconds", "guardian-bond-duration-seconds",
|
||||
"guardian-bond-range-blocks", "guardian-bond-redirect-percent"),
|
||||
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "guardian-bond-"}));
|
||||
assertEquals(List.of("10", "14"), fixture.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "guardian-bond-range-blocks", ""}));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-range-blocks"});
|
||||
assertTrue(sender.messages.getLast().contains("=14"));
|
||||
for (String[] update : List.of(
|
||||
new String[]{"guardian-bond-range-blocks", "16"},
|
||||
new String[]{"guardian-bond-cooldown-seconds", "3"},
|
||||
new String[]{"guardian-bond-duration-seconds", "9"},
|
||||
new String[]{"guardian-bond-redirect-percent", "50"})) {
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", update[0], update[1]});
|
||||
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
}
|
||||
var configured = registry.find("guardian_bond").orElseThrow();
|
||||
assertEquals(new BigDecimal("16"), configured.parameters().get("range"));
|
||||
assertEquals(java.time.Duration.ofSeconds(3), configured.cooldown());
|
||||
assertEquals(0, new BigDecimal("9").compareTo(configured.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("50"), configured.parameters().get("redirect-percent"));
|
||||
|
||||
var guardian = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareGuardian(data, guardian, true); fixture.players.put(guardian.core.id, guardian.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(guardian); fixture.activate(ally); party(fixture, guardian, ally); aim(guardian, ally, 15, true);
|
||||
nativeDamage(fixture, guardian); use(fixture, guardian); drain(fixture, 2);
|
||||
var future = hit(mock(org.bukkit.entity.Zombie.class), ally.player, 10); fixture.dispatcher.accept(future);
|
||||
assertEquals(5, future.getDamage(), 1.0e-9); assertEquals(180L, fixture.delayedTicks.getLast());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-duration-seconds", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-range-blocks", "16", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var afterRestart = reloaded.find("guardian_bond").orElseThrow();
|
||||
assertEquals(new BigDecimal("16"), afterRestart.parameters().get("range"));
|
||||
assertEquals(java.time.Duration.ofSeconds(3), afterRestart.cooldown());
|
||||
assertEquals(0, new BigDecimal("9").compareTo(afterRestart.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("50"), afterRestart.parameters().get("redirect-percent"));
|
||||
}
|
||||
for (String invalid : List.of("guardian-bond-cooldown-seconds: -1\n", "guardian-bond-range-blocks: 0\n",
|
||||
"guardian-bond-duration-seconds: 0\n", "guardian-bond-redirect-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 fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true)); assertTrue(fixture.order.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class Context {
|
||||
final org.bukkit.World world = mock(org.bukkit.World.class); final Player guardian = mock(Player.class); final Player ally = mock(Player.class);
|
||||
final LivingEntity enemy = mock(LivingEntity.class); final EquipmentSessions sessions = mock(EquipmentSessions.class);
|
||||
final PartyRoster parties = mock(PartyRoster.class); final AbilityExecution execution = mock(AbilityExecution.class);
|
||||
final ControlInput input = mock(ControlInput.class); final List<GuardianBond.Support> supports = new ArrayList<>();
|
||||
GuardianBond.Damage damage = (target, attacker, amount) -> amount; final GuardianBond bond;
|
||||
final EquipmentSessions.Active guardianActive; final EquipmentSessions.Active allyActive;
|
||||
final PartyRoster.Party party;
|
||||
Context() {
|
||||
var guardianId = UUID.randomUUID(); var allyId = UUID.randomUUID();
|
||||
when(guardian.getUniqueId()).thenReturn(guardianId); when(ally.getUniqueId()).thenReturn(allyId);
|
||||
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(guardian.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
when(ally.getLocation()).thenReturn(new Location(world, 8, 64, 0));
|
||||
when(guardian.hasLineOfSight(ally)).thenReturn(true); when(ally.hasLineOfSight(guardian)).thenReturn(true);
|
||||
guardianActive = new EquipmentSessions.Active(guardianId, UUID.randomUUID(), UUID.randomUUID());
|
||||
allyActive = new EquipmentSessions.Active(allyId, UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(allyId)).thenReturn(java.util.Optional.of(allyActive)); when(sessions.active(guardianId)).thenReturn(java.util.Optional.of(guardianActive));
|
||||
when(sessions.current(allyActive)).thenReturn(true); when(sessions.current(guardianActive)).thenReturn(true);
|
||||
party = new PartyRoster.Party(UUID.randomUUID(), guardianId, List.of(guardianId, allyId));
|
||||
when(parties.partyOf(guardianId)).thenReturn(java.util.Optional.of(party)); when(parties.partyOf(allyId)).thenReturn(java.util.Optional.of(party));
|
||||
when(input.player()).thenReturn(guardian); when(input.target()).thenReturn(ally); when(input.session()).thenReturn(guardianActive);
|
||||
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
|
||||
bond = new GuardianBond(GuardianBond.Settings.defaults(), sessions, parties,
|
||||
(target, attacker, amount) -> damage.apply(target, attacker, amount), supports::add);
|
||||
when(execution.definition()).thenReturn(bond.definition());
|
||||
}
|
||||
void activate() { bond.activate(execution); }
|
||||
Context reverse() {
|
||||
var reverse = new Context();
|
||||
when(reverse.input.player()).thenReturn(ally); when(reverse.input.target()).thenReturn(guardian); when(reverse.input.session()).thenReturn(allyActive);
|
||||
when(reverse.execution.input()).thenReturn(reverse.input); when(reverse.execution.definition()).thenReturn(bond.definition());
|
||||
bond.activate(reverse.execution); return reverse;
|
||||
}
|
||||
}
|
||||
|
||||
private static void nativeDamage(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
actor.damage = (amount, source) -> {
|
||||
var event = hit(source, actor.player, amount); fixture.dispatcher.accept(event);
|
||||
if (!event.isCancelled()) actor.core.health = Math.max(0, actor.core.health - event.getFinalDamage());
|
||||
};
|
||||
}
|
||||
private static EntityDamageByEntityEvent hit(Entity attacker, Entity target, double amount) {
|
||||
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(attacker).withDirectEntity(attacker).build();
|
||||
return new EntityDamageByEntityEvent(attacker, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, amount);
|
||||
}
|
||||
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 PartyRoster.Party party(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor source,
|
||||
FantasyStickLifecycleNativeTest.Actor target) {
|
||||
var parties = PartyLifecycleNativeTest.parties(fixture); var group = parties.create(source.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(target.core.id, parties.invite(source.core.id, group.id(), target.core.id).orElseThrow().id()));
|
||||
return parties.partyOf(source.core.id).orElseThrow();
|
||||
}
|
||||
private static void aim(FantasyStickLifecycleNativeTest.Actor source,
|
||||
FantasyStickLifecycleNativeTest.Actor target, double distance, boolean visible) {
|
||||
aim(source, target.player, distance, visible);
|
||||
}
|
||||
private static void aim(FantasyStickLifecycleNativeTest.Actor source, Player target, double distance, boolean visible) {
|
||||
doReturn(new Location(source.world, 0, 64, 0)).when(source.player).getLocation();
|
||||
doReturn(new Location(source.world, distance, 64, 0)).when(target).getLocation();
|
||||
doReturn(visible).when(source.player).hasLineOfSight(target); doReturn(target).when(source.player).getTargetEntity(64);
|
||||
}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
|
||||
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
}
|
||||
private static ClickEvent.Custom button(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 = button(page.raw(), label, ability); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + ability);
|
||||
}
|
||||
private static ClickEvent.Custom button(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 = button(child, label, ability); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
private static void prepareGuardian(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
|
||||
stats.put(Stat.CON, BigInteger.valueOf(16)); stats.put(Stat.WIS, BigInteger.TEN);
|
||||
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 ? Set.of("guardian_bond") : old.learned(),
|
||||
learned ? List.of("guardian_bond", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
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.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class HealthStealNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void connectedLearnBasicProjectileFantasyDefeatDuplicateAssistAndTransferPathPersistsOnlyFinalBlows() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"character-swap-cooldown-seconds: 0\nhealth-steal-healing-per-kill: 1\n"
|
||||
+ "out-of-combat-healing-delay-seconds: 31536000\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); assertTrue(f.order.contains("HealthSteal"));
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); makeEligible(data, a, BigDecimal.valueOf(4));
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); makeEligibleAtLevelCap(data, b, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a); f.activate(b);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("HealthStealSTR14,CON12[Learn]"));
|
||||
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "health_steal")); settle(f);
|
||||
var learned = record(f, a); assertEquals("health_steal", learned.passives().getFirst());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints()); var activeLoadout = learned.loadout();
|
||||
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var basicTarget = mob(EntityType.COW, new Location(world, 1, 64, 0), 1);
|
||||
doAnswer(call -> { hit(f, a.player, basicTarget, (Double) call.getArgument(0), false); return null; })
|
||||
.when(basicTarget).damage(anyDouble(), same(a.player));
|
||||
a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, basicTarget, true));
|
||||
die(f, basicTarget); settle(f); settle(f);
|
||||
assertEquals(5, a.player.getHealth()); assertEquals(0, BigDecimal.valueOf(5).compareTo(record(f, a).health()));
|
||||
die(f, basicTarget); settle(f); assertEquals(5, a.player.getHealth(), "A repeated death callback is consumed");
|
||||
|
||||
var projectileTarget = mob(EntityType.ZOMBIE, new Location(world, 2, 64, 0), 1);
|
||||
var arrow = PartyCombatNativeTest.projectile(f, a.player);
|
||||
hit(f, arrow, projectileTarget, 4, false); die(f, projectileTarget); settle(f); settle(f);
|
||||
assertEquals(6, a.player.getHealth(), "Originating-character projectile attribution heals its final blow");
|
||||
var spacingBlocked = mob(EntityType.ZOMBIE, new Location(world, 2, 64, 0), 1);
|
||||
hit(f, a.player, spacingBlocked, 4, false); die(f, spacingBlocked); settle(f); settle(f);
|
||||
assertEquals(7, a.player.getHealth(), "Restricted-XP spacing does not suppress final-blow healing");
|
||||
|
||||
var assisted = mob(EntityType.ZOMBIE, new Location(world, 3, 64, 0), 10);
|
||||
hit(f, a.player, assisted, 1, false); when(assisted.getHealth()).thenReturn(1d);
|
||||
hit(f, b.player, assisted, 4, false); die(f, assisted); settle(f); settle(f);
|
||||
assertEquals(7, a.player.getHealth(), "Contribution without the final blow does not heal");
|
||||
var capped = record(f, b); assertEquals(5, b.player.getHealth()); assertEquals(30, capped.level());
|
||||
assertEquals(BigInteger.valueOf(2_900), capped.experience(),
|
||||
"The final blower heals with multiple contributors even when XP is already capped");
|
||||
|
||||
b.player.setHealth(1); hit(f, a.player, b.player, 4, false);
|
||||
var defeat = new org.bukkit.event.entity.PlayerDeathEvent(b.player, mock(org.bukkit.damage.DamageSource.class),
|
||||
new ArrayList<>(), 0, net.kyori.adventure.text.Component.text("defeat"), true);
|
||||
f.dispatcher.accept(defeat); settle(f); settle(f);
|
||||
assertTrue(defeat.isCancelled()); assertEquals(8, a.player.getHealth(), "Enemy fantasy-character final blows qualify");
|
||||
|
||||
var saved = record(f, a); assertEquals(activeLoadout, saved.loadout());
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
var transferred = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
transferred.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(saved));
|
||||
f.players.put(transferred.core.id, transferred.player); f.activate(transferred);
|
||||
assertEquals(8, transferred.player.getHealth());
|
||||
assertEquals(saved.passives(), storage(f).find(saved.id()).get().orElseThrow().passives());
|
||||
PartyBookNativeTest.open(f, transferred);
|
||||
PartyBookNativeTest.click(f, transferred, action(transferred, "[Unlearn]", "health_steal")); settle(f);
|
||||
var unlearned = storage(f).find(saved.id()).get().orElseThrow();
|
||||
assertFalse(unlearned.passives().contains("health_steal"));
|
||||
assertEquals(saved.unspentPoints().add(BigInteger.ONE), unlearned.unspentPoints());
|
||||
var afterUnlearn = mob(EntityType.COW, new Location(world, 5, 64, 0), 1);
|
||||
hit(f, transferred.player, afterUnlearn, 4, false); die(f, afterUnlearn); settle(f);
|
||||
assertEquals(8, transferred.player.getHealth(), "Unlearning removes only the passive's future healing");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configuredZeroAndOrdinaryPlayerTargetPerformNoHealthWriteOrPublication() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"health-steal-healing-per-kill: 0\nout-of-combat-healing-delay-seconds: 31536000\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, a, BigDecimal.valueOf(4), List.of("health_steal", "", ""));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var before = record(f, a); var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var target = mob(EntityType.WARDEN, new Location(world, 0, 64, 0), 1);
|
||||
hit(f, a.player, target, 4, false); die(f, target); settle(f);
|
||||
assertEquals(0, before.health().compareTo(record(f, a).health())); assertEquals(4, a.player.getHealth());
|
||||
|
||||
var ordinary = mock(org.bukkit.entity.Player.class); when(ordinary.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(ordinary.getHealth()).thenReturn(1d); when(ordinary.getAbsorptionAmount()).thenReturn(0d);
|
||||
hit(f, a.player, ordinary, 4, false);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.PlayerDeathEvent(ordinary, mock(org.bukkit.damage.DamageSource.class),
|
||||
new ArrayList<>(), 0, net.kyori.adventure.text.Component.text("death"), false));
|
||||
settle(f); assertEquals(4, a.player.getHealth(), "Ordinary player targets never qualify");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void changedValueCapsAtFullHealthAndCancellationFailureOrStaleSourceNeverPublishes() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"character-swap-cooldown-seconds: 0\nhealth-steal-healing-per-kill: 3\n"
|
||||
+ "out-of-combat-healing-delay-seconds: 31536000\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
class Gate implements org.bukkit.event.Listener {
|
||||
final java.util.ArrayList<Double> amounts = new java.util.ArrayList<>(); boolean veto;
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void heal(org.bukkit.event.entity.EntityRegainHealthEvent event) {
|
||||
amounts.add(event.getAmount()); if (veto) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
var gate = new Gate(); f.plugin.getServer().getPluginManager().registerEvents(gate, f.plugin);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
|
||||
var changed = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, changed, BigDecimal.valueOf(4), List.of("health_steal", "", ""));
|
||||
f.players.put(changed.core.id, changed.player); f.activate(changed);
|
||||
var first = mob(EntityType.BEE, new Location(world, 0, 64, 0), 1);
|
||||
hit(f, changed.player, first, 4, false); die(f, first); settle(f); settle(f);
|
||||
assertEquals(7, changed.player.getHealth()); assertEquals(List.of(3d), gate.amounts);
|
||||
|
||||
gate.veto = true; var cancelled = mob(EntityType.COW, new Location(world, 1, 64, 0), 1);
|
||||
hit(f, changed.player, cancelled, 4, false); die(f, cancelled); settle(f);
|
||||
assertEquals(7, changed.player.getHealth()); assertEquals(List.of(3d, 3d), gate.amounts);
|
||||
gate.veto = false;
|
||||
int beforeInvalidDamage = gate.amounts.size();
|
||||
var cancelledDamage = mob(EntityType.COW, new Location(world, 1.25, 64, 0), 1);
|
||||
hit(f, changed.player, cancelledDamage, 4, true); die(f, cancelledDamage); settle(f);
|
||||
var zeroDamage = mob(EntityType.COW, new Location(world, 1.5, 64, 0), 1);
|
||||
hit(f, changed.player, zeroDamage, 0, false); die(f, zeroDamage); settle(f);
|
||||
assertEquals(beforeInvalidDamage, gate.amounts.size(), "Cancelled and zero damage cannot establish a final blow");
|
||||
|
||||
var full = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, full, BigDecimal.valueOf(22), List.of("health_steal", "", ""));
|
||||
f.players.put(full.core.id, full.player); f.activate(full); int beforeFull = gate.amounts.size();
|
||||
var fullTarget = mob(EntityType.WARDEN, new Location(world, 2, 64, 0), 1);
|
||||
hit(f, full.player, fullTarget, 4, false); die(f, fullTarget); settle(f);
|
||||
assertEquals(beforeFull, gate.amounts.size()); assertEquals(22, full.player.getHealth());
|
||||
|
||||
var stale = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, stale, BigDecimal.valueOf(4), List.of("health_steal", "", ""));
|
||||
f.players.put(stale.core.id, stale.player); f.activate(stale);
|
||||
var staleTarget = mob(EntityType.ZOMBIE, new Location(world, 3, 64, 0), 1);
|
||||
hit(f, stale.player, staleTarget, 4, false);
|
||||
stale.core.inventory.setItemInOffHand(null); f.changed(stale); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(stale);
|
||||
int beforeStale = gate.amounts.size(); die(f, staleTarget); settle(f);
|
||||
assertEquals(beforeStale, gate.amounts.size(), "A transferred source cannot receive redirected healing");
|
||||
|
||||
var failed = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, failed, BigDecimal.valueOf(4), List.of("health_steal", "", ""));
|
||||
f.players.put(failed.core.id, failed.player); f.activate(failed);
|
||||
var failedTarget = mob(EntityType.COW, new Location(world, 4, 64, 0), 1);
|
||||
hit(f, failed.player, failedTarget, 4, false);
|
||||
Runnable callback;
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters").resolve(".records.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid()); die(f, failedTarget);
|
||||
callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback);
|
||||
}
|
||||
callback.run(); assertEquals(4, failed.player.getHealth());
|
||||
assertEquals(0, BigDecimal.valueOf(4).compareTo(record(f, failed).health()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configurationIsDurablePermissionAwareAndPrefixFiltered() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("health-steal-healing-per-kill"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "health-steal"}));
|
||||
assertEquals(List.of("1"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "health-steal-healing-per-kill", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "health-steal-healing-per-kill"});
|
||||
assertTrue(sender.messages.getLast().contains("=1")); sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "health-steal"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "health-steal-healing-per-kill", "3"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "health-steal-healing-per-kill", "3"});
|
||||
var callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var settings = (games.dmg.fantasystick.persistence.HealthStealSettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthStealSettings");
|
||||
assertEquals("3", settings.value());
|
||||
}
|
||||
}
|
||||
|
||||
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
|
||||
makeEligible(data, actor, health, List.of("", "", ""));
|
||||
}
|
||||
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health,
|
||||
List<String> passives) 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.STR, BigInteger.valueOf(14)); stats.put(Stat.CON, BigInteger.valueOf(12));
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), BigInteger.valueOf(40), health, old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
private static void makeEligibleAtLevelCap(Path data, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
BigDecimal health) throws Exception {
|
||||
makeEligible(data, actor, health, List.of("health_steal", "", ""));
|
||||
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(),
|
||||
30, BigInteger.valueOf(2_900), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Entity source,
|
||||
org.bukkit.entity.Entity target, double amount, 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, EntityDamageEvent.DamageCause.ENTITY_ATTACK, damage, amount);
|
||||
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 LivingEntity mob(EntityType type, Location location, double health) {
|
||||
var entity = mock(LivingEntity.class); when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(entity.getType()).thenReturn(type); when(entity.getHealth()).thenReturn(health); when(entity.getAbsorptionAmount()).thenReturn(0d);
|
||||
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;
|
||||
}
|
||||
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
return storage(f).find(actor.core.record.id()).get().orElseThrow();
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(content);
|
||||
for (var page : content.pages()) { var found = action(page.raw(), label, passive); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + passive);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
net.minecraft.network.chat.Component component, String label, String passive) {
|
||||
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(":" + passive)) return custom;
|
||||
for (var child : component.getSiblings()) { var found = action(child, label, passive); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,8 @@ class HexNativeTest {
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(book.contains("Hex")); assertTrue(book.contains("CHR14")); assertTrue(book.contains("WIS12"));
|
||||
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("12")); assertTrue(book.contains("20%")); assertTrue(book.contains("4s"));
|
||||
assertFalse(book.contains("Cost:")); assertTrue(book.contains("12")); assertTrue(book.contains("20%")); assertTrue(book.contains("4s"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class); var enemyId = java.util.UUID.randomUUID();
|
||||
when(enemy.getUniqueId()).thenReturn(enemyId); when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
@@ -174,7 +173,7 @@ class HexNativeTest {
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("hex"));}}
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,abilityButton(a,"[Learn]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,abilityButton(a,"[3]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,abilityButton(a,"[Learn]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
|
||||
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));}
|
||||
|
||||
@@ -2,7 +2,7 @@ package games.dmg.fantasystick;
|
||||
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.Mockito.*;
|
||||
import games.dmg.fantasystick.persistence.*;import games.dmg.fantasystick.progression.Stat;import java.math.BigInteger;import java.nio.file.Path;import java.util.*;import net.minecraft.network.chat.ClickEvent;import org.bukkit.event.player.PlayerInteractEvent;import org.bukkit.inventory.EquipmentSlot;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
|
||||
class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void bookAcquisitionSneakUseAddsOwnedTemporaryHealthAndExpiryPreservesUnrelatedAbsorption()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(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 book=PartyBookNativeTest.text(a).replaceAll("\\s+","");assertTrue(book.contains("Inspire"));assertTrue(book.contains("CHR12"));assertTrue(book.contains("WIS10"));assertTrue(book.contains("Cost:1"));assertTrue(book.contains("12"));assertTrue(book.contains("6s"));PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,button(a,"[3]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
@Test void bookAcquisitionSneakUseAddsOwnedTemporaryHealthAndExpiryPreservesUnrelatedAbsorption()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(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 book=PartyBookNativeTest.text(a).replaceAll("\\s+","");assertTrue(book.contains("Inspire"));assertTrue(book.contains("CHR12"));assertTrue(book.contains("WIS10"));assertFalse(book.contains("Cost:"));assertTrue(book.contains("12"));assertTrue(book.contains("6s"));PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
double maximum=a.maximumHealth.getBaseValue();var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);assertEquals(maximum,a.maximumHealth.getBaseValue(),1e-9);assertEquals(120L,f.delayedTicks.getLast());f.delayed.getLast().run();assertEquals(2,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
|
||||
@Test void aimedPartyAllyActualAbsorptionCreatesPersistedSupportShare()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);withInspire(data,a,true);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()));
|
||||
doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
|
||||
@@ -20,7 +20,7 @@ class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()thr
|
||||
var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(10,absorption.get(),1e-9);assertEquals(160L,f.delayedTicks.getLast());
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","-1"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reload=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new java.math.BigDecimal("9"),reload.find("inspire").orElseThrow().parameters().get("base-temporary-health"));}
|
||||
for(String invalid:List.of("inspire-base-temporary-health: -1\n","inspire-cooldown-seconds: -1\n","inspire-range-blocks: 0\n","inspire-duration-seconds: 0\n")){var dir=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),invalid);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}}
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,button(a,"[3]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));}
|
||||
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor a,String label,String ability){var b=org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast()).get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);assertNotNull(b);for(var p:b.pages()){var x=button(p.raw(),label,ability);if(x!=null)return x;}throw new AssertionError("missing action");}
|
||||
private static ClickEvent.Custom button(net.minecraft.network.chat.Component c,String label,String ability){if(c.getString().equals(label)&&c.getStyle().getClickEvent() instanceof ClickEvent.Custom x&&x.payload().orElse(null) instanceof net.minecraft.nbt.StringTag p&&p.value().contains(":"+ability))return x;for(var k:c.getSiblings()){var x=button(k,label,ability);if(x!=null)return x;}return null;}
|
||||
|
||||
@@ -45,12 +45,10 @@ class LongbowNativeTest {
|
||||
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);
|
||||
assertFalse(text.contains("Cost:"), 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());
|
||||
@@ -411,8 +409,6 @@ class LongbowNativeTest {
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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.passive.LowLightVision;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class LowLightVisionNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void connectedLearnExitTransferUnlearnFailureAndDefeatOwnOnlyCharacterNightVision() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); withWis(data, a, List.of("", "", ""));
|
||||
var aEffects = effects(a.player); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var vision = (LowLightVision) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "lowLightVision");
|
||||
PartyBookNativeTest.open(f, a);
|
||||
assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("Low-LightVisionWIS12[Learn]"));
|
||||
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "low_light_vision")); settle(f); vision.sweep();
|
||||
var learned = record(f, a); assertEquals(List.of("low_light_vision", "", ""), learned.passives());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
|
||||
assertEquals(0, aEffects.get(PotionEffectType.NIGHT_VISION).getAmplifier());
|
||||
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone(); a.core.inventory.setItemInOffHand(null);
|
||||
f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a); vision.sweep();
|
||||
assertFalse(aEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); var bEffects = effects(b.player);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b); vision.sweep();
|
||||
assertTrue(bEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
PartyBookNativeTest.open(f, b); PartyBookNativeTest.click(f, b, action(b, "[Unlearn]", "low_light_vision")); settle(f); vision.sweep();
|
||||
var unlearned = storage(f).find(learned.id()).get().orElseThrow(); assertFalse(unlearned.passives().contains("low_light_vision"));
|
||||
assertEquals(learned.unspentPoints().add(BigInteger.ONE), unlearned.unspentPoints());
|
||||
assertFalse(bEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
|
||||
PartyBookNativeTest.open(f, b); Runnable failed;
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters").resolve(".records.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid()); PartyBookNativeTest.click(f, b, action(b, "[Learn]", "low_light_vision"));
|
||||
failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed);
|
||||
}
|
||||
failed.run(); vision.sweep(); assertFalse(bEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
assertFalse(storage(f).find(learned.id()).get().orElseThrow().passives().contains("low_light_vision"));
|
||||
|
||||
b.core.inventory.setItemInOffHand(null); f.changed(b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
storage(f).find(learned.id()).get(); f.receipts().find(b.core.id).toCompletableFuture().get();
|
||||
withWis(data, a, List.of("low_light_vision", "", ""));
|
||||
var c = new FantasyStickLifecycleNativeTest.Actor(data); var cEffects = effects(c.player);
|
||||
c.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(
|
||||
new YamlCharacterRepository(data).load(a.core.record.id()).orElseThrow()));
|
||||
f.players.put(c.core.id, c.player); f.activate(c); vision.sweep(); assertTrue(cEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
c.player.setHealth(1);
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(c.player, mock(org.bukkit.damage.DamageSource.class),
|
||||
new java.util.ArrayList<>(), 0, net.kyori.adventure.text.Component.text("defeat"), true);
|
||||
f.dispatcher.accept(death); settle(f); vision.sweep(); assertFalse(cEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void milkAndUnrelatedNightVisionReconcileWithoutTouchingBlindnessOrDarkness() {
|
||||
var sessions = mock(EquipmentSessions.class); var player = mock(Player.class); var effects = effects(player);
|
||||
UUID id = UUID.randomUUID(); var active = new EquipmentSessions.Active(id, UUID.randomUUID(), UUID.randomUUID());
|
||||
var record = mock(CharacterRecord.class); when(record.passives()).thenReturn(List.of("low_light_vision", "", ""));
|
||||
when(player.getUniqueId()).thenReturn(id); when(sessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(active, record)));
|
||||
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
|
||||
when(sessions.currentPlayer(active)).thenReturn(java.util.Optional.of(player));
|
||||
var blindness = new PotionEffect(PotionEffectType.BLINDNESS, 100, 0);
|
||||
var darkness = new PotionEffect(PotionEffectType.DARKNESS, 100, 0);
|
||||
effects.put(PotionEffectType.BLINDNESS, blindness); effects.put(PotionEffectType.DARKNESS, darkness);
|
||||
var unrelated = new PotionEffect(PotionEffectType.NIGHT_VISION, 40, 0); effects.put(PotionEffectType.NIGHT_VISION, unrelated);
|
||||
var vision = new LowLightVision(sessions); assertEquals(Map.of(Stat.WIS, BigInteger.valueOf(12)), vision.definition().requirements());
|
||||
vision.sweep(); assertSame(unrelated, effects.get(PotionEffectType.NIGHT_VISION));
|
||||
effects.remove(PotionEffectType.NIGHT_VISION); vision.sweep(); assertTrue(effects.get(PotionEffectType.NIGHT_VISION).isInfinite());
|
||||
assertSame(blindness, effects.get(PotionEffectType.BLINDNESS)); assertSame(darkness, effects.get(PotionEffectType.DARKNESS));
|
||||
effects.remove(PotionEffectType.NIGHT_VISION); vision.sweep();
|
||||
verify(player, times(2)).addPotionEffect(argThat(effect -> effect.getType().equals(PotionEffectType.NIGHT_VISION)), eq(true));
|
||||
when(record.passives()).thenReturn(List.of("", "", "")); vision.sweep();
|
||||
assertFalse(effects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
assertSame(blindness, effects.get(PotionEffectType.BLINDNESS)); assertSame(darkness, effects.get(PotionEffectType.DARKNESS));
|
||||
vision.close();
|
||||
}
|
||||
|
||||
private static Map<PotionEffectType, PotionEffect> effects(Player player) {
|
||||
var result = new HashMap<PotionEffectType, PotionEffect>();
|
||||
doAnswer(call -> result.get(call.getArgument(0))).when(player).getPotionEffect(any());
|
||||
doAnswer(call -> { PotionEffect effect = call.getArgument(0); result.put(effect.getType(), effect); return true; })
|
||||
.when(player).addPotionEffect(any(), eq(true));
|
||||
doAnswer(call -> { result.remove(call.getArgument(0)); return null; }).when(player).removePotionEffect(any()); return result;
|
||||
}
|
||||
private static void withWis(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> passives) throws Exception {
|
||||
var repository = new YamlCharacterRepository(data); var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new EnumMap<>(old.stats()); 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(),
|
||||
old.unspentPoints(), old.health(), old.learned(), old.loadout(), passives, old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
return storage(f).find(actor.core.record.id()).get().orElseThrow();
|
||||
}
|
||||
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(content);
|
||||
for (var page : content.pages()) { var found = action(page.raw(), label, passive); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + passive);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
net.minecraft.network.chat.Component component, String label, String passive) {
|
||||
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(":" + passive)) return custom;
|
||||
for (var child : component.getSiblings()) { var found = action(child, label, passive); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
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.ability.AbilityExecution;
|
||||
import games.dmg.fantasystick.ability.MendingLight;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.MendingLightSettings;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
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.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
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 MendingLightNativeTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionAimedPartyHealPersistsCapsAndKeepsCooldownAcrossReloadAndRelearn() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareCaster(data, caster, false);
|
||||
setHealth(data, ally, BigDecimal.valueOf(4));
|
||||
fixture.players.put(caster.core.id, caster.player);
|
||||
fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 8, true);
|
||||
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("MendingLight")); assertTrue(text.contains("WIS12"));
|
||||
assertTrue(text.contains("CHR10")); assertFalse(text.contains("Cost:"));
|
||||
assertTrue(text.contains("12")); assertTrue(text.contains("8"));
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "mending_light"));
|
||||
drain(fixture, 2);
|
||||
|
||||
use(fixture, caster); drain(fixture, 3);
|
||||
var storage = storage(fixture);
|
||||
var healed = storage.find(ally.core.record.id()).get().orElseThrow();
|
||||
assertEquals(0, BigDecimal.TEN.compareTo(healed.health()), "6 + WIS modifier caps at ally maximum");
|
||||
assertEquals(10, ally.player.getHealth());
|
||||
var deadline = storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("mending_light");
|
||||
assertNotNull(deadline);
|
||||
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
var unlearn = button(caster, "[Unlearn]", "mending_light");
|
||||
PartyBookNativeTest.click(fixture, caster, unlearn); PartyBookNativeTest.click(fixture, caster, unlearn);
|
||||
drain(fixture, 2);
|
||||
assertEquals(deadline, storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("mending_light"));
|
||||
learnAndAssign(fixture, caster); use(fixture, caster);
|
||||
assertTrue(caster.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
|
||||
var allySheet = ally.core.inventory.getItemInOffHand().clone();
|
||||
ally.core.inventory.setItemInOffHand(null); fixture.changed(ally); drain(fixture, 1); fixture.awaitCharacterAndReceipt(ally);
|
||||
var resumed = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
resumed.core.inventory.setItemInOffHand(allySheet); fixture.players.put(resumed.core.id, resumed.player); fixture.activate(resumed);
|
||||
assertEquals(10, resumed.player.getHealth(), "Healed health survives exit, transfer and resume");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualHealingCreatesOnePersistedSupportShareForAnAllyDirectContributor() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareCaster(data, caster, true); setHealth(data, ally, BigDecimal.valueOf(4));
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 8, true);
|
||||
use(fixture, caster); drain(fixture, 3);
|
||||
var sourceSheet = caster.core.inventory.getItemInOffHand().clone();
|
||||
caster.core.inventory.setItemInOffHand(null); fixture.changed(caster); drain(fixture, 1); fixture.awaitCharacterAndReceipt(caster);
|
||||
var currentHolder = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
currentHolder.core.inventory.setItemInOffHand(sourceSheet);
|
||||
fixture.players.put(currentHolder.core.id, currentHolder.player); fixture.activate(currentHolder);
|
||||
|
||||
var enemy = mock(LivingEntity.class);
|
||||
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(caster.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getLocation()).thenReturn(new Location(caster.world, 8, 64, 0));
|
||||
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(ally.player).withDirectEntity(ally.player).build();
|
||||
fixture.dispatcher.accept(new EntityDamageByEntityEvent(ally.player, enemy,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 3));
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0));
|
||||
drain(fixture, 4);
|
||||
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
assertTrue(currentHolder.messages.stream().anyMatch(message -> message.contains("5") && message.contains("XP")),
|
||||
"Effective healing follows the living source character through transfer");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void selfOrdinaryNonPartyFullHealthRangeAndLineOfSightRefuseBeforeCooldown() throws Exception {
|
||||
for (int route = 0; route < 6; route++) {
|
||||
var directory = data.resolve("refusal-" + route);
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(directory); prepareCaster(directory, caster, true);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
|
||||
Player target;
|
||||
if (route == 0) target = caster.player;
|
||||
else if (route == 1) { target = mock(Player.class); when(target.getUniqueId()).thenReturn(UUID.randomUUID()); }
|
||||
else {
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(directory);
|
||||
setHealth(directory, ally, route == 3 ? BigDecimal.TEN : BigDecimal.valueOf(4));
|
||||
fixture.players.put(ally.core.id, ally.player); fixture.activate(ally); target = ally.player;
|
||||
if (route != 2) party(fixture, caster, ally);
|
||||
}
|
||||
aim(caster, target, route == 4 ? 13 : 8, route != 5);
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertFalse(storage(fixture).find(caster.core.record.id()).get().orElseThrow()
|
||||
.cooldowns().containsKey("mending_light"), "route " + route + " must refuse before cooldown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledHealingConsumesCooldownButAppliesNoHealthOrSupport() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareCaster(data, caster, true); setHealth(data, ally, BigDecimal.valueOf(4));
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 8, true);
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler public void heal(EntityRegainHealthEvent event) {
|
||||
if (event.getEntity() == ally.player) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
fixture.dispatcher.register(new Veto(), fixture.plugin);
|
||||
use(fixture, caster); drain(fixture, 3);
|
||||
assertEquals(4, ally.player.getHealth());
|
||||
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("mending_light"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void targetSaveFailureOrSessionReplacementPublishesNoNativeHealingOrSupport() {
|
||||
var characters = mock(CharacterStorage.class); var sessions = mock(EquipmentSessions.class);
|
||||
var parties = mock(PartyRoster.class); var source = mock(Player.class); var target = mock(Player.class);
|
||||
var attribute = mock(AttributeInstance.class); when(target.getAttribute(Attribute.MAX_HEALTH)).thenReturn(attribute);
|
||||
when(attribute.getValue()).thenReturn(10.0); when(target.getHealth()).thenReturn(4.0);
|
||||
when(source.getUniqueId()).thenReturn(UUID.randomUUID()); when(target.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var world = mock(org.bukkit.World.class);
|
||||
when(source.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
when(target.getLocation()).thenReturn(new Location(world, 8, 64, 0));
|
||||
when(source.hasLineOfSight(target)).thenReturn(true);
|
||||
var casterRecord = recordWith(UUID.randomUUID(), "mending_light", BigDecimal.TEN);
|
||||
var targetRecord = recordWith(UUID.randomUUID(), null, BigDecimal.valueOf(4));
|
||||
var casterActive = new EquipmentSessions.Active(source.getUniqueId(), UUID.randomUUID(), casterRecord.id());
|
||||
var targetActive = new EquipmentSessions.Active(target.getUniqueId(), UUID.randomUUID(), targetRecord.id());
|
||||
when(sessions.active(target.getUniqueId())).thenReturn(java.util.Optional.of(targetActive));
|
||||
when(sessions.current(targetActive)).thenReturn(true); when(sessions.currentRecord(targetActive)).thenReturn(java.util.Optional.of(targetRecord));
|
||||
var party = new PartyRoster.Party(UUID.randomUUID(), source.getUniqueId(), List.of(source.getUniqueId(), target.getUniqueId()));
|
||||
when(parties.partyOf(source.getUniqueId())).thenReturn(java.util.Optional.of(party));
|
||||
when(parties.partyOf(target.getUniqueId())).thenReturn(java.util.Optional.of(party));
|
||||
var input = mock(ControlInput.class); when(input.player()).thenReturn(source); when(input.target()).thenReturn(target); when(input.session()).thenReturn(casterActive);
|
||||
var execution = mock(AbilityExecution.class); when(execution.stillCurrent()).thenReturn(true);
|
||||
when(execution.input()).thenReturn(input); when(execution.character()).thenReturn(casterRecord);
|
||||
var supports = new ArrayList<MendingLight.Support>();
|
||||
var effect = new MendingLight(MendingLight.Settings.defaults(), characters, sessions, parties, Runnable::run,
|
||||
ignored -> {}, () -> BigDecimal.valueOf(1024), supports::add);
|
||||
when(execution.definition()).thenReturn(effect.definition());
|
||||
var failed = new CompletableFuture<java.util.Optional<CharacterRecord>>();
|
||||
when(characters.saveHealth(eq(targetRecord.id()), eq(targetRecord.revision()), any(), any())).thenReturn(failed);
|
||||
effect.activate(execution); failed.completeExceptionally(new java.io.IOException("simulated"));
|
||||
verify(target, never()).setHealth(anyDouble()); assertTrue(supports.isEmpty()); verify(execution).finish();
|
||||
|
||||
var acknowledged = new CharacterRecord(targetRecord.id(), targetRecord.name(), targetRecord.revision().add(BigInteger.ONE),
|
||||
targetRecord.stats(), targetRecord.level(), targetRecord.experience(), targetRecord.unspentPoints(), BigDecimal.TEN,
|
||||
targetRecord.learned(), targetRecord.loadout(), targetRecord.cooldowns(), targetRecord.restrictedKills(), false, null, null);
|
||||
var delayed = new CompletableFuture<java.util.Optional<CharacterRecord>>();
|
||||
when(characters.saveHealth(eq(targetRecord.id()), eq(targetRecord.revision()), any(), any())).thenReturn(delayed);
|
||||
when(sessions.current(targetActive)).thenReturn(true);
|
||||
effect.activate(execution);
|
||||
when(sessions.current(targetActive)).thenReturn(false);
|
||||
delayed.complete(java.util.Optional.of(acknowledged));
|
||||
verify(target, never()).setHealth(anyDouble()); assertTrue(supports.isEmpty());
|
||||
verify(sessions, never()).acknowledgeProgress(eq(targetActive), any(), any());
|
||||
verify(execution, times(2)).finish();
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"mending-light-base-healing: 8\nmending-light-cooldown-seconds: 0\nmending-light-range-blocks: 16\n");
|
||||
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 definition = registry.find("mending_light").orElseThrow();
|
||||
assertEquals(java.time.Duration.ZERO, definition.cooldown());
|
||||
assertEquals(new BigDecimal("8"), definition.parameters().get("base-healing"));
|
||||
assertEquals(0, new BigDecimal("16").compareTo(definition.parameters().get("range")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "mending-light-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "9"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("mending-light-base-healing", "mending-light-cooldown-seconds", "mending-light-range-blocks"),
|
||||
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "mending-light-"}));
|
||||
assertEquals(List.of("6", "8"), fixture.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "mending-light-base-healing", ""}));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing"});
|
||||
assertTrue(sender.messages.getLast().contains("=8"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "9"});
|
||||
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertEquals(new BigDecimal("9"), registry.find("mending_light").orElseThrow().parameters().get("base-healing"));
|
||||
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareCaster(data, caster, true); setHealth(data, ally, BigDecimal.valueOf(1));
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 15, true);
|
||||
use(fixture, caster); drain(fixture, 3); assertEquals(10, ally.player.getHealth());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "-1"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "9", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
assertEquals(new BigDecimal("9"), reloaded.find("mending_light").orElseThrow().parameters().get("base-healing"));
|
||||
}
|
||||
for (String invalid : List.of("mending-light-base-healing: -1\n", "mending-light-cooldown-seconds: -1\n",
|
||||
"mending-light-range-blocks: 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 fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true)); assertTrue(fixture.order.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void exactFormulaSupportsUncappedWisdomAndFloorDivision() {
|
||||
assertEquals(BigInteger.valueOf(7), MendingLight.healing(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.valueOf(5), MendingLight.healing(BigInteger.valueOf(9)));
|
||||
var huge = BigInteger.TEN.pow(500);
|
||||
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)), MendingLight.healing(huge));
|
||||
}
|
||||
|
||||
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, button(actor, "[Learn]", "mending_light")); drain(fixture, 2);
|
||||
}
|
||||
|
||||
private static PartyRoster.Party party(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor caster,
|
||||
FantasyStickLifecycleNativeTest.Actor ally) {
|
||||
var parties = PartyLifecycleNativeTest.parties(fixture); var group = parties.create(caster.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(ally.core.id, parties.invite(caster.core.id, group.id(), ally.core.id).orElseThrow().id()));
|
||||
return parties.partyOf(caster.core.id).orElseThrow();
|
||||
}
|
||||
|
||||
private static void aim(FantasyStickLifecycleNativeTest.Actor caster,
|
||||
FantasyStickLifecycleNativeTest.Actor target, double distance, boolean visible) {
|
||||
aim(caster, target.player, distance, visible);
|
||||
}
|
||||
|
||||
private static void aim(FantasyStickLifecycleNativeTest.Actor caster, Player target, double distance, boolean visible) {
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
|
||||
doReturn(new Location(caster.world, distance, 64, 0)).when(target).getLocation();
|
||||
doReturn(visible).when(caster.player).hasLineOfSight(target);
|
||||
doReturn(target).when(caster.player).getTargetEntity(64);
|
||||
}
|
||||
|
||||
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 void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
|
||||
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom button(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 = button(page.raw(), label, ability); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + ability);
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom button(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 = button(child, label, ability); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void prepareCaster(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
|
||||
stats.put(Stat.WIS, BigInteger.valueOf(12)); stats.put(Stat.CHR, BigInteger.TEN);
|
||||
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 ? Set.of("mending_light") : old.learned(),
|
||||
learned ? List.of("mending_light", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
|
||||
private static void setHealth(Path directory, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
|
||||
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
|
||||
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(), health, old.learned(), old.loadout(), old.cooldowns(),
|
||||
old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
|
||||
private static CharacterRecord recordWith(UUID id, String ability, BigDecimal health) {
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new EnumMap<>(base.stats()); stats.put(Stat.WIS, BigInteger.valueOf(12)); stats.put(Stat.CHR, BigInteger.TEN);
|
||||
return new CharacterRecord(id, base.name(), base.revision(), stats, base.level(), base.experience(), base.unspentPoints(),
|
||||
health, ability == null ? Set.of() : Set.of(ability), ability == null ? base.loadout() : List.of(ability, "", "", "", "", "", ""),
|
||||
base.cooldowns(), base.restrictedKills(), false, null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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.ability.*;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.*;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.*;
|
||||
import java.math.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.*;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.*;
|
||||
import org.bukkit.event.entity.*;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.util.*;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class MeteorNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
|
||||
@Test void characterBookPurchaseEquippedGroundWarningAndNativeImpact()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false);f.players.put(caster.core.id,caster.player);f.activate(caster);var placement=placement(caster,24,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,new UUID(0,2),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{double amount=call.getArgument(0);damaged.add(amount);var source=(DamageSource)call.getArgument(1);f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,enemy,EntityDamageEvent.DamageCause.MAGIC,source,amount));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);verify(caster.world,atLeast(40)).spawnParticle(eq(Particle.DUST),any(Location.class),eq(24),anyDouble(),anyDouble(),anyDouble(),eq(0d),any(Particle.DustOptions.class));verify(caster.world,atLeastOnce()).spawnParticle(eq(Particle.EXPLOSION_EMITTER),any(Location.class),eq(2),anyDouble(),anyDouble(),anyDouble(),anyDouble());verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void missingOrSideGroundRefusesBeforeCooldown()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.UP);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));}}
|
||||
|
||||
@Test void impactRechecksGroundPartyModeGeometryObstructionCancellationAndSourceLifecycle(){
|
||||
var d=direct();var escaping=target(d.world,new UUID(0,1),2,65);var joined=mock(Player.class);locate(joined,d.world,new UUID(0,2),2,65,1);var ordinary=mock(Player.class);locate(ordinary,d.world,new UUID(0,3),2,65,2);var inactive=mock(Player.class);locate(inactive,d.world,new UUID(0,8),2,65,3);var inactiveSession=new EquipmentSessions.Active(inactive.getUniqueId(),UUID.randomUUID(),UUID.randomUUID());when(d.sessions.active(inactive.getUniqueId())).thenReturn(Optional.of(inactiveSession));when(d.sessions.current(inactiveSession)).thenReturn(false);var blocked=target(d.world,new UUID(0,4),4.5,65);var cancelled=target(d.world,new UUID(0,5),2,65);var boundary=mock(LivingEntity.class);locate(boundary,d.world,new UUID(0,6),5.5,69,.5);var outside=mock(LivingEntity.class);locate(outside,d.world,new UUID(0,7),5.51,69,.5);doReturn(List.of(escaping,joined,ordinary,inactive,blocked,cancelled,boundary,outside,d.caster)).when(d.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var joinedActive=new EquipmentSessions.Active(joined.getUniqueId(),UUID.randomUUID(),UUID.randomUUID());when(d.sessions.active(joined.getUniqueId())).thenReturn(Optional.of(joinedActive));when(d.sessions.current(joinedActive)).thenReturn(true);var party=new PartyRoster.Party(UUID.randomUUID(),d.caster.getUniqueId(),List.of(d.caster.getUniqueId(),joined.getUniqueId()));when(d.parties.partyOf(d.caster.getUniqueId())).thenReturn(Optional.of(party));when(d.parties.partyOf(joined.getUniqueId())).thenReturn(Optional.of(party));doReturn(mock(RayTraceResult.class)).when(d.world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),doubleThat(distance->distance>4&&distance<4.5),eq(FluidCollisionMode.NEVER),eq(true));when(escaping.getLocation()).thenReturn(new Location(d.world,6,65,.5));doAnswer(call->{var event=new EntityDamageEvent(cancelled,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0));event.setCancelled(true);d.meteor.acceptedDamage(event);return null;}).when(cancelled).damage(anyDouble(),any(DamageSource.class));doAnswer(call->{d.meteor.acceptedDamage(new EntityDamageEvent(boundary,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0)));return null;}).when(boundary).damage(anyDouble(),any(DamageSource.class));d.meteor.activate(d.execution);run(d.scheduled,40);verify(escaping,never()).damage(anyDouble(),any(DamageSource.class));verify(joined,never()).damage(anyDouble(),any(DamageSource.class));verify(ordinary,never()).damage(anyDouble(),any(DamageSource.class));verify(inactive,never()).damage(anyDouble(),any(DamageSource.class));verify(blocked,never()).damage(anyDouble(),any(DamageSource.class));verify(cancelled).damage(anyDouble(),any(DamageSource.class));verify(boundary).damage(anyDouble(),any(DamageSource.class));verify(outside,never()).damage(anyDouble(),any(DamageSource.class));verify(d.execution,times(1)).report(eq(AbilityReport.Type.DAMAGE),eq(boundary),any());
|
||||
var invalid=direct();invalid.meteor.activate(invalid.execution);invalid.blockType.set(Material.AIR);run(invalid.scheduled,40);verify(invalid.execution,never()).report(any(),any(),any());verify(invalid.world,never()).createExplosion(any(Location.class),anyFloat());
|
||||
var ended=direct();ended.meteor.activate(ended.execution);when(ended.execution.stillCurrent()).thenReturn(false);run(ended.scheduled,1);verify(ended.execution).finish();
|
||||
}
|
||||
|
||||
@Test void impactUsesExactCylinderDistanceUuidOrderAndSharedBoundsOnce(){var d=direct();var targets=new ArrayList<LivingEntity>();var order=new ArrayList<UUID>();for(int i=0;i<257;i++){var target=target(d.world,new UUID(0,i+1),i==256?0:Math.min(5,i/20d),65+(i%2==0?4:0));targets.add(target);doAnswer(call->{order.add(target.getUniqueId());d.meteor.acceptedDamage(new EntityDamageEvent(target,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0)));return null;}).when(target).damage(anyDouble(),any(DamageSource.class));}doReturn(new ArrayList<Entity>(targets)).when(d.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());d.meteor.activate(d.execution);run(d.scheduled,40);assertEquals(64,order.size());assertFalse(order.contains(targets.get(256).getUniqueId()),"Candidates after the 256 inspection ceiling are ignored");assertEquals(new UUID(0,11),order.getFirst());verify(d.execution,times(64)).report(eq(AbilityReport.Type.DAMAGE),any(),any());assertEquals(BigInteger.valueOf(22),Meteor.damage(BigInteger.valueOf(26)));}
|
||||
|
||||
@Test void configuredDefinitionAdminRestartAndFutureImpactCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"meteor-base-damage: 16\nmeteor-cooldown-seconds: 3\nmeteor-target-range-blocks: 20\nmeteor-warning-seconds: 1\nmeteor-radius-blocks: 6\nmeteor-vertical-range-blocks: 3\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var definition=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("16"),definition.parameters().get("base-damage"));assertEquals(java.time.Duration.ofSeconds(3),definition.cooldown());var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(6,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).size());assertEquals(List.of("5","6"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,""}));for(String[] update:List.of(new String[]{MeteorSettings.BASE_DAMAGE,"18"},new String[]{MeteorSettings.COOLDOWN,"4"},new String[]{MeteorSettings.TARGET_RANGE,"22"},new String[]{MeteorSettings.WARNING,"1.5"},new String[]{MeteorSettings.RADIUS,"7"},new String[]{MeteorSettings.VERTICAL,"5"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}var configured=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("18"),configured.parameters().get("base-damage"));assertEquals(0,new BigDecimal("1.5").compareTo(configured.parameters().get("warning-seconds")));
|
||||
var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);placement(caster,22,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,UUID.randomUUID(),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{damaged.add(call.getArgument(0));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));use(f,caster);drain(f,2);for(int i=0;i<30;i++)f.delayed.removeFirst().run();assertEquals(List.of(26d),damaged);
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("18"),reloaded.find("meteor").orElseThrow().parameters().get("base-damage"));}}
|
||||
|
||||
private record Direct(Meteor meteor,AbilityExecution execution,Player caster,World world,EquipmentSessions sessions,PartyRoster parties,ArrayDeque<Runnable> scheduled,java.util.concurrent.atomic.AtomicReference<Material> blockType){}
|
||||
private static Direct direct(){var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var caster=mock(Player.class);var world=mock(World.class);UUID id=UUID.randomUUID();when(caster.getUniqueId()).thenReturn(id);when(caster.getWorld()).thenReturn(world);when(caster.getLocation()).thenReturn(new Location(world,0,65,0));when(caster.getBoundingBox()).thenReturn(new BoundingBox(0,65,0,1,67,1));var active=new EquipmentSessions.Active(id,UUID.randomUUID(),UUID.randomUUID());when(sessions.active(id)).thenReturn(Optional.of(active));when(sessions.current(active)).thenReturn(true);var type=new java.util.concurrent.atomic.AtomicReference<>(Material.STONE);var block=mock(Block.class);when(block.getType()).thenAnswer(call->type.get());when(block.getWorld()).thenReturn(world);when(block.getLocation()).thenReturn(new Location(world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(BlockFace.UP);when(caster.getEyeLocation()).thenReturn(new Location(world,0,65.62,0,0,45));doReturn(ray).when(world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(24d),eq(FluidCollisionMode.NEVER),eq(true));var execution=mock(AbilityExecution.class);var input=mock(ControlInput.class);when(input.player()).thenReturn(caster);when(input.session()).thenReturn(active);when(execution.input()).thenReturn(input);when(execution.stillCurrent()).thenReturn(true);var character=mock(CharacterRecord.class);var stats=new EnumMap<Stat,BigInteger>(Stat.class);for(var stat:Stat.values())stats.put(stat,BigInteger.ZERO);stats.put(Stat.INT,BigInteger.valueOf(26));when(character.stats()).thenReturn(stats);when(execution.character()).thenReturn(character);var scheduled=new ArrayDeque<Runnable>();doAnswer(call->{scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);}).when(execution).later(any(),any());var meteor=new Meteor(Meteor.Settings.defaults(),sessions,parties);when(execution.definition()).thenReturn(meteor.definition());return new Direct(meteor,execution,caster,world,sessions,parties,scheduled,type);}
|
||||
private static Block placement(FantasyStickLifecycleNativeTest.Actor actor,double range,BlockFace face){var block=mock(Block.class);when(block.getType()).thenReturn(Material.STONE);when(block.getWorld()).thenReturn(actor.world);when(block.getLocation()).thenReturn(new Location(actor.world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(face);doReturn(new Location(actor.world,0,65.62,0,0,45)).when(actor.player).getEyeLocation();doReturn(ray).when(actor.world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(range),eq(FluidCollisionMode.NEVER),eq(true));return block;}
|
||||
private static LivingEntity target(World world,UUID id,double x,double y){var target=mock(LivingEntity.class);locate(target,world,id,x,y,0);return target;}
|
||||
private static void locate(LivingEntity target,World world,UUID id,double x,double y,double z){when(target.getUniqueId()).thenReturn(id);when(target.getLocation()).thenReturn(new Location(world,x,y,z));when(target.getBoundingBox()).thenReturn(new BoundingBox(x-.3,y,z-.3,x+.3,y+1.8,z+.3));when(target.getHeight()).thenReturn(1.8);}
|
||||
private static void run(ArrayDeque<Runnable> scheduled,int count){for(int i=0;i<count;i++){assertFalse(scheduled.isEmpty(),"missing warning tick "+i);scheduled.removeFirst().run();}}
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,BlockFace.SELF,EquipmentSlot.HAND));}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture f,int count)throws Exception{for(int i=0;i<count;i++)PartyBookNativeTest.drain(f);}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f){return(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");}
|
||||
private static ClickEvent.Custom button(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=button(page.raw(),label,ability);if(found!=null)return found;}throw new AssertionError("Missing "+label);}
|
||||
private static ClickEvent.Custom button(net.minecraft.network.chat.Component c,String label,String ability){if(c.getString().equals(label)&&c.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:c.getSiblings()){var found=button(child,label,ability);if(found!=null)return found;}return null;}
|
||||
private static void prepare(Path directory,FantasyStickLifecycleNativeTest.Actor actor,boolean learned)throws Exception{java.nio.file.Files.createDirectories(directory);var repository=new YamlCharacterRepository(directory);var old=repository.load(actor.core.record.id()).orElseThrow();var stats=new EnumMap<>(old.stats());stats.put(Stat.INT,BigInteger.valueOf(26));stats.put(Stat.WIS,BigInteger.valueOf(20));stats.put(Stat.CON,BigInteger.valueOf(12));repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),stats,old.level(),old.experience(),BigInteger.valueOf(learned?0:1),old.health(),learned?Set.of("meteor"):old.learned(),learned?List.of("meteor","","","","","",""):old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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.ProgressionRules;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import games.dmg.fantasystick.session.OutOfCombatRecovery;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class OutOfCombatRecoveryNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void exactDelayRepeatedIntervalsAndLargeClockJumpsPersistOnePulseBeforeNativePublication() {
|
||||
var f = new Direct();
|
||||
f.recovery.sweep();
|
||||
f.clock.advance(Duration.ofSeconds(29)); f.recovery.sweep();
|
||||
assertTrue(f.events.isEmpty());
|
||||
|
||||
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep();
|
||||
assertEquals(1, f.events.size());
|
||||
assertEquals(EntityRegainHealthEvent.RegainReason.CUSTOM, f.events.getFirst().getRegainReason());
|
||||
assertEquals(4, f.health.get());
|
||||
f.runNext();
|
||||
assertEquals(5, f.health.get());
|
||||
assertEquals(0, BigDecimal.valueOf(5).compareTo(f.record.get().health()));
|
||||
|
||||
f.clock.advance(Duration.ofSeconds(9)); f.recovery.sweep();
|
||||
assertEquals(1, f.events.size());
|
||||
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep(); f.runNext();
|
||||
assertEquals(6, f.health.get());
|
||||
|
||||
f.clock.advance(Duration.ofSeconds(100)); f.recovery.sweep(); f.runNext();
|
||||
assertEquals(7, f.health.get(), "A late evaluation applies one pulse rather than replaying missed intervals");
|
||||
assertEquals(3, f.events.size());
|
||||
|
||||
f.health.set(10d);
|
||||
var revision = f.record.get().revision();
|
||||
f.clock.advance(Duration.ofSeconds(10)); f.recovery.sweep();
|
||||
assertEquals(3, f.events.size(), "Full health dispatches no native healing event");
|
||||
assertEquals(revision, f.record.get().revision(), "Full health performs no persistence write");
|
||||
}
|
||||
|
||||
@Test void acceptedEnvironmentalAndDealtDamageResetWhileCancelledAndZeroAttemptsDoNot() {
|
||||
var f = new Direct(); f.recovery.sweep();
|
||||
f.clock.advance(Duration.ofSeconds(29));
|
||||
f.recovery.acceptedDamage(damage(f.player, 2));
|
||||
f.clock.advance(Duration.ofSeconds(29)); f.recovery.sweep(); assertTrue(f.events.isEmpty());
|
||||
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep(); f.runNext(); assertEquals(5, f.health.get());
|
||||
|
||||
var noReset = new Direct(); noReset.recovery.sweep(); noReset.clock.advance(Duration.ofSeconds(29));
|
||||
var cancelled = damage(noReset.player, 2); cancelled.setCancelled(true); noReset.recovery.acceptedDamage(cancelled);
|
||||
noReset.recovery.acceptedDamage(damage(noReset.player, 0));
|
||||
noReset.clock.advance(Duration.ofSeconds(1)); noReset.recovery.sweep(); noReset.runNext();
|
||||
assertEquals(5, noReset.health.get());
|
||||
|
||||
var dealt = new Direct();
|
||||
var target = mock(Player.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
dealt.recovery.sweep(); dealt.clock.advance(Duration.ofSeconds(29));
|
||||
var source = DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(dealt.player).withDirectEntity(dealt.player).build();
|
||||
dealt.recovery.acceptedDamage(new EntityDamageByEntityEvent(dealt.player, target,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 2));
|
||||
dealt.clock.advance(Duration.ofSeconds(29)); dealt.recovery.sweep(); assertTrue(dealt.events.isEmpty());
|
||||
dealt.clock.advance(Duration.ofSeconds(1)); dealt.recovery.sweep(); dealt.runNext();
|
||||
assertEquals(5, dealt.health.get(), "Accepted outgoing damage starts a fresh delay for its source character");
|
||||
}
|
||||
|
||||
@Test void cancellationFailureAndDamageDuringPersistenceNeverPublishOrCatchUp() {
|
||||
var cancelled = new Direct(); cancelled.veto = true; cancelled.recovery.sweep();
|
||||
cancelled.clock.advance(Duration.ofSeconds(30)); cancelled.recovery.sweep();
|
||||
assertEquals(1, cancelled.events.size()); assertTrue(cancelled.callbacks.isEmpty()); assertEquals(4, cancelled.health.get());
|
||||
cancelled.clock.advance(Duration.ofSeconds(9)); cancelled.recovery.sweep(); assertEquals(1, cancelled.events.size());
|
||||
|
||||
var failed = new Direct(); failed.automatic = false; failed.recovery.sweep(); failed.clock.advance(Duration.ofSeconds(30));
|
||||
failed.recovery.sweep(); assertNotNull(failed.pending);
|
||||
failed.pending.completeExceptionally(new java.io.IOException("simulated")); failed.runNext();
|
||||
assertEquals(4, failed.health.get());
|
||||
failed.clock.advance(Duration.ofSeconds(9)); failed.recovery.sweep();
|
||||
assertEquals(1, failed.events.size(), "Failed persistence does not immediately replay a missed pulse");
|
||||
|
||||
var stale = new Direct(); stale.automatic = false; stale.recovery.sweep(); stale.clock.advance(Duration.ofSeconds(30));
|
||||
stale.recovery.sweep();
|
||||
stale.recovery.acceptedDamage(damage(stale.player, 1));
|
||||
stale.pending.complete(Optional.of(stale.saved(BigDecimal.valueOf(5)))); stale.runNext();
|
||||
assertEquals(4, stale.health.get(), "Damage during the save invalidates native publication");
|
||||
stale.clock.advance(Duration.ofSeconds(29)); stale.recovery.sweep(); assertEquals(1, stale.events.size());
|
||||
}
|
||||
|
||||
@Test void passiveBonusZeroCancellationAndFullHealthStayInTheBasePulseTransaction() {
|
||||
var zero = new Direct(); zero.recovery.attachBonusHealing(ignored -> BigInteger.ZERO);
|
||||
zero.recovery.sweep(); zero.clock.advance(Duration.ofSeconds(30)); zero.recovery.sweep(); zero.runNext();
|
||||
assertEquals(5, zero.health.get(), "A zero passive bonus leaves the base pulse unchanged");
|
||||
|
||||
var cancelled = new Direct(); cancelled.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); cancelled.veto = true;
|
||||
var cancelledRevision = cancelled.record.get().revision();
|
||||
cancelled.recovery.sweep(); cancelled.clock.advance(Duration.ofSeconds(30)); cancelled.recovery.sweep();
|
||||
assertEquals(4, cancelled.events.getFirst().getAmount());
|
||||
assertEquals(4, cancelled.health.get()); assertEquals(cancelledRevision, cancelled.record.get().revision());
|
||||
|
||||
var full = new Direct(); full.health.set(10d); full.record.set(full.saved(BigDecimal.TEN));
|
||||
full.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); var fullRevision = full.record.get().revision();
|
||||
full.recovery.sweep(); full.clock.advance(Duration.ofSeconds(30)); full.recovery.sweep();
|
||||
assertTrue(full.events.isEmpty()); assertEquals(fullRevision, full.record.get().revision());
|
||||
}
|
||||
|
||||
@Test void stackedRegenerationBonusProducesOneMaximumCappedNativeAndPersistedPulse() {
|
||||
var f = new Direct(); f.health.set(9d); f.record.set(f.saved(BigDecimal.valueOf(9)));
|
||||
f.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3));
|
||||
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(30)); f.recovery.sweep();
|
||||
assertEquals(1, f.events.size()); assertEquals(1, f.events.getFirst().getAmount());
|
||||
f.runNext(); assertEquals(10, f.health.get()); assertEquals(0, BigDecimal.TEN.compareTo(f.record.get().health()));
|
||||
}
|
||||
|
||||
@Test void zeroAmountDisablesPulsesAndReconfigurationRetainsTheActivityTimestamp() {
|
||||
var f = new Direct(new OutOfCombatRecovery.Settings(Duration.ofSeconds(30), Duration.ofSeconds(10), BigInteger.ZERO));
|
||||
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(40)); f.recovery.sweep();
|
||||
assertTrue(f.events.isEmpty()); assertEquals(4, f.health.get());
|
||||
f.recovery.configure(new OutOfCombatRecovery.Settings(Duration.ofSeconds(60), Duration.ofSeconds(5), BigInteger.ONE));
|
||||
f.clock.advance(Duration.ofSeconds(19)); f.recovery.sweep(); assertTrue(f.events.isEmpty());
|
||||
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep(); f.runNext();
|
||||
assertEquals(5, f.health.get(), "The new delay evaluates from the retained activity timestamp");
|
||||
}
|
||||
|
||||
@Test void packagedPluginConnectsZeroDelayRecoveryConfigurationAndRestart() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 10\nout-of-combat-healing-amount: 1\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); setHealth(data, a, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
|
||||
recovery.sweep(); PartyBookNativeTest.drain(f);
|
||||
assertEquals(5, a.player.getHealth());
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(0, BigDecimal.valueOf(5).compareTo(storage.find(a.core.record.id()).get().orElseThrow().health()));
|
||||
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("out-of-combat-healing-amount", "out-of-combat-healing-delay-seconds",
|
||||
"out-of-combat-healing-interval-seconds"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "out-of-combat"}));
|
||||
assertEquals(List.of("1"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "out-of-combat-healing-amount", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "out-of-combat-healing-amount"});
|
||||
assertTrue(sender.messages.getLast().contains("=1"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "out-of-combat-healing-amount", "3"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var settings = (games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecoverySettings");
|
||||
assertEquals("3", settings.value(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.AMOUNT));
|
||||
}
|
||||
}
|
||||
|
||||
private static EntityDamageEvent damage(Player player, double amount) {
|
||||
return new EntityDamageEvent(player, EntityDamageEvent.DamageCause.FALL, mock(DamageSource.class), amount);
|
||||
}
|
||||
|
||||
private static void setHealth(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
|
||||
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(), health, old.learned(), old.loadout(), old.cooldowns(),
|
||||
old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
|
||||
private static final class MutableClock extends Clock {
|
||||
Instant now = Instant.parse("2026-09-21T00:00:00Z");
|
||||
void advance(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; }
|
||||
}
|
||||
|
||||
private static final class Direct {
|
||||
final CharacterStorage characters = mock(CharacterStorage.class);
|
||||
final EquipmentSessions sessions = mock(EquipmentSessions.class);
|
||||
final Player player = mock(Player.class);
|
||||
final AttributeInstance maximum = mock(AttributeInstance.class);
|
||||
final MutableClock clock = new MutableClock();
|
||||
final AtomicReference<Double> health = new AtomicReference<>(4d);
|
||||
final AtomicReference<CharacterRecord> record = new AtomicReference<>();
|
||||
final EquipmentSessions.Active active;
|
||||
final ArrayDeque<Runnable> callbacks = new ArrayDeque<>();
|
||||
final java.util.ArrayList<EntityRegainHealthEvent> events = new java.util.ArrayList<>();
|
||||
OutOfCombatRecovery recovery;
|
||||
CompletableFuture<Optional<CharacterRecord>> pending;
|
||||
boolean automatic = true;
|
||||
boolean veto;
|
||||
|
||||
Direct() { this(OutOfCombatRecovery.Settings.defaults()); }
|
||||
Direct(OutOfCombatRecovery.Settings settings) {
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
|
||||
record.set(new CharacterRecord(base.id(), base.name(), BigInteger.ONE, base.stats(), base.level(), base.experience(),
|
||||
base.unspentPoints(), BigDecimal.valueOf(4), base.learned(), base.loadout(), base.cooldowns(),
|
||||
base.restrictedKills(), false, null, null));
|
||||
active = new EquipmentSessions.Active(java.util.UUID.randomUUID(), java.util.UUID.randomUUID(), base.id());
|
||||
when(player.getUniqueId()).thenReturn(active.player()); when(player.getHealth()).thenAnswer(call -> health.get());
|
||||
doAnswer(call -> { health.set(call.getArgument(0)); return null; }).when(player).setHealth(anyDouble());
|
||||
when(player.getAttribute(Attribute.MAX_HEALTH)).thenReturn(maximum); when(maximum.getValue()).thenReturn(10d);
|
||||
when(sessions.active(active.player())).thenReturn(Optional.of(active));
|
||||
when(sessions.current(active)).thenReturn(true);
|
||||
when(sessions.currentPlayer(active)).thenReturn(Optional.of(player));
|
||||
when(sessions.currentRecord(active)).thenAnswer(call -> Optional.of(record.get()));
|
||||
when(sessions.activeCharacters()).thenAnswer(call -> List.of(new EquipmentSessions.ActiveCharacter(active, record.get())));
|
||||
when(sessions.acknowledgeProgress(eq(active), any(), any())).thenAnswer(call -> {
|
||||
var expected = (BigInteger) call.getArgument(1); var saved = (CharacterRecord) call.getArgument(2);
|
||||
if (!record.get().revision().equals(expected)) return false;
|
||||
record.set(saved); return true;
|
||||
});
|
||||
when(characters.saveHealth(eq(base.id()), any(BigInteger.class), any(BigDecimal.class), any(BigDecimal.class)))
|
||||
.thenAnswer(call -> {
|
||||
var target = (BigDecimal) call.getArgument(2);
|
||||
pending = new CompletableFuture<>();
|
||||
if (automatic) pending.complete(Optional.of(saved(target)));
|
||||
return pending;
|
||||
});
|
||||
recovery = new OutOfCombatRecovery(settings, characters, sessions, clock, callbacks::add,
|
||||
event -> { events.add(event); if (veto) event.setCancelled(true); }, () -> BigDecimal.valueOf(1024), ignored -> {});
|
||||
}
|
||||
|
||||
CharacterRecord saved(BigDecimal target) {
|
||||
var old = record.get();
|
||||
return new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), target, old.learned(), old.loadout(), old.cooldowns(),
|
||||
old.restrictedKills(), false, null, null);
|
||||
}
|
||||
void runNext() { assertFalse(callbacks.isEmpty(), "Missing deferred recovery callback"); callbacks.removeFirst().run(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.ability.AbilityExecution;
|
||||
import games.dmg.fantasystick.ability.AbilityReport;
|
||||
import games.dmg.fantasystick.ability.OutgoingDamageBonus;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class OutgoingDamageBonusNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void strongestDeterministicGrantAppliesOnceAndReportsOnlyAcceptedAllyUse() throws Exception {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class);
|
||||
var recipient = mock(Player.class); var enemy = mock(LivingEntity.class);
|
||||
var recipientId = UUID.randomUUID(); when(recipient.getUniqueId()).thenReturn(recipientId);
|
||||
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var aided = new EquipmentSessions.Active(recipientId, UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(recipientId)).thenReturn(java.util.Optional.of(aided)); when(sessions.current(aided)).thenReturn(true);
|
||||
var sourcePlayer = mock(Player.class); when(sourcePlayer.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var party = new PartyRoster.Party(UUID.randomUUID(), sourcePlayer.getUniqueId(), List.of(sourcePlayer.getUniqueId(), recipientId));
|
||||
when(parties.partyOf(sourcePlayer.getUniqueId())).thenReturn(java.util.Optional.of(party));
|
||||
when(parties.partyOf(recipientId)).thenReturn(java.util.Optional.of(party));
|
||||
var supports = new ArrayList<OutgoingDamageBonus.Support>();
|
||||
var bonuses = new OutgoingDamageBonus(sessions, parties, supports::add);
|
||||
var first = execution(sourcePlayer, UUID.randomUUID()); var strongest = execution(sourcePlayer, UUID.randomUUID());
|
||||
var tiedLater = execution(sourcePlayer, UUID.randomUUID());
|
||||
var firstGrant = bonuses.grant(first, recipient, new BigDecimal("20"), () -> true);
|
||||
var strongestGrant = bonuses.grant(strongest, recipient, new BigDecimal("25"), () -> true);
|
||||
var tiedGrant = bonuses.grant(tiedLater, recipient, new BigDecimal("25"), () -> true);
|
||||
|
||||
var hit = hit(recipient, enemy, 10); bonuses.increase(hit); assertEquals(12.5, hit.getDamage(), 1.0e-9);
|
||||
bonuses.accepted(hit);
|
||||
assertEquals(1, supports.size()); assertEquals(0, new BigDecimal("2.50").compareTo(supports.getFirst().amount()));
|
||||
assertEquals(strongest.input().session().character(), supports.getFirst().supportingCharacter());
|
||||
verify(strongest).report(eq(AbilityReport.Type.BOOST_USED), eq(enemy),
|
||||
argThat(amount -> amount.compareTo(new BigDecimal("2.50")) == 0));
|
||||
verify(first, never()).report(any(), any(), any()); verify(tiedLater, never()).report(any(), any(), any());
|
||||
|
||||
var cancelled = hit(recipient, enemy, 10); bonuses.increase(cancelled); cancelled.setCancelled(true); bonuses.accepted(cancelled);
|
||||
assertEquals(1, supports.size());
|
||||
firstGrant.close(); strongestGrant.close(); tiedGrant.close(); bonuses.close();
|
||||
}
|
||||
|
||||
@Test void invalidOrSelfUseAndMarkedGuardianRedirectionCreateNoSupportOrRepeatedBonus() throws Exception {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class);
|
||||
var player = mock(Player.class); var enemy = mock(LivingEntity.class); var id = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(id); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var active = new EquipmentSessions.Active(id, UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(id)).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
|
||||
var supports = new ArrayList<OutgoingDamageBonus.Support>(); var bonuses = new OutgoingDamageBonus(sessions, parties, supports::add);
|
||||
var valid = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
var self = execution(player, active.character()); bonuses.grant(self, player, new BigDecimal("20"), valid::get);
|
||||
var hit = hit(player, enemy, 10); bonuses.increase(hit); bonuses.accepted(hit);
|
||||
assertEquals(12, hit.getDamage(), 1.0e-9); assertTrue(supports.isEmpty());
|
||||
when(sessions.current(active)).thenReturn(false); valid.set(false);
|
||||
var stale = hit(player, enemy, 10); bonuses.increase(stale); assertEquals(10, stale.getDamage(), 1.0e-9);
|
||||
bonuses.close();
|
||||
}
|
||||
|
||||
private static AbilityExecution execution(Player source, UUID character) {
|
||||
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
|
||||
var active = new EquipmentSessions.Active(source.getUniqueId(), UUID.randomUUID(), character);
|
||||
when(input.player()).thenReturn(source); when(input.session()).thenReturn(active);
|
||||
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true); return execution;
|
||||
}
|
||||
private static EntityDamageByEntityEvent hit(Player attacker, LivingEntity target, double amount) {
|
||||
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(attacker).withDirectEntity(attacker).build();
|
||||
return new EntityDamageByEntityEvent(attacker, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
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.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import net.minecraft.world.effect.MobEffectInstance;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class PurifyNativeTest {
|
||||
private static List<PotionEffectType> harmful;
|
||||
@TempDir Path data;
|
||||
|
||||
@BeforeAll static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
harmful = List.of(PotionEffectType.POISON, PotionEffectType.WITHER,
|
||||
PotionEffectType.SLOWNESS, PotionEffectType.WEAKNESS);
|
||||
}
|
||||
|
||||
@Test void bookAcquisitionSneakUseRemovesEveryHarmfulLayerAndPreservesBeneficialEffectsAndCooldown() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, actor, false);
|
||||
fixture.players.put(actor.core.id, actor.player); fixture.activate(actor);
|
||||
var effects = effects(fixture, actor.player);
|
||||
harmful.forEach(type -> effects.put(type, layered(type)));
|
||||
effects.put(PotionEffectType.REGENERATION, instance(PotionEffectType.REGENERATION, 200, 1));
|
||||
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
String book = PartyBookNativeTest.text(actor).replaceAll("\\s+", "");
|
||||
assertTrue(book.contains("Purify")); assertTrue(book.contains("WIS14")); assertTrue(book.contains("CHR10"));
|
||||
assertFalse(book.contains("Cost:")); assertTrue(book.contains("15")); assertTrue(book.contains("12"));
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "purify")); drain(fixture, 2);
|
||||
doReturn(true).when(actor.player).isSneaking(); use(fixture, actor); drain(fixture, 2);
|
||||
|
||||
harmful.forEach(type -> assertFalse(effects.containsKey(type), type + " and its hidden chain must be gone"));
|
||||
assertTrue(effects.containsKey(PotionEffectType.REGENERATION));
|
||||
var storage = storage(fixture); var saved = storage.find(actor.core.record.id()).get().orElseThrow();
|
||||
var deadline = saved.cooldowns().get("purify"); assertNotNull(deadline);
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
var unlearn = button(actor, "[Unlearn]", "purify");
|
||||
PartyBookNativeTest.click(fixture, actor, unlearn); PartyBookNativeTest.click(fixture, actor, unlearn); drain(fixture, 2);
|
||||
assertEquals(deadline, storage.find(actor.core.record.id()).get().orElseThrow().cooldowns().get("purify"));
|
||||
learnAndAssign(fixture, actor); use(fixture, actor);
|
||||
assertTrue(actor.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void aimedAllyActualCleanseCreatesPersistedSupportShareThatFollowsSourceCharacter() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepare(data, caster, true); fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally.player, 8, true);
|
||||
var effects = effects(fixture, ally.player); effects.put(PotionEffectType.POISON, layered(PotionEffectType.POISON));
|
||||
doReturn(false).when(caster.player).isSneaking(); use(fixture, caster); drain(fixture, 2);
|
||||
assertFalse(effects.containsKey(PotionEffectType.POISON));
|
||||
|
||||
var sheet = caster.core.inventory.getItemInOffHand().clone(); caster.core.inventory.setItemInOffHand(null);
|
||||
fixture.changed(caster); drain(fixture, 1); fixture.awaitCharacterAndReceipt(caster);
|
||||
var holder = new FantasyStickLifecycleNativeTest.Actor(data); holder.core.inventory.setItemInOffHand(sheet);
|
||||
fixture.players.put(holder.core.id, holder.player); fixture.activate(holder);
|
||||
|
||||
var enemy = mock(LivingEntity.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(caster.world.getUID()).thenReturn(UUID.randomUUID()); when(enemy.getLocation()).thenReturn(new Location(caster.world, 8, 64, 0));
|
||||
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(ally.player).withDirectEntity(ally.player).build();
|
||||
fixture.dispatcher.accept(new EntityDamageByEntityEvent(ally.player, enemy,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 3));
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
assertTrue(holder.messages.stream().anyMatch(message -> message.contains("5") && message.contains("XP")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void normalSelfOrdinaryNonPartyOutOfRangeObstructedAndNoOpTargetsRefuseBeforeCooldown() throws Exception {
|
||||
for (int route = 0; route < 6; route++) {
|
||||
var directory = data.resolve("refusal-" + route);
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(directory); prepare(directory, caster, true);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
|
||||
Player target;
|
||||
if (route == 0) { target = caster.player; doReturn(false).when(caster.player).isSneaking(); }
|
||||
else if (route == 1) { target = mock(Player.class); when(target.getUniqueId()).thenReturn(UUID.randomUUID()); }
|
||||
else {
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(directory); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(ally); target = ally.player; effects(fixture, ally.player);
|
||||
if (route != 2) party(fixture, caster, ally);
|
||||
}
|
||||
if (route != 5 && route > 1) effectMap(target).put(PotionEffectType.WEAKNESS, instance(PotionEffectType.WEAKNESS, 200, 0));
|
||||
aim(caster, target, route == 3 ? 13 : 8, route != 4); doReturn(false).when(caster.player).isSneaking();
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertFalse(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("purify"),
|
||||
"route " + route + " must refuse before cooldown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledRemovalAndSelfCleanseCreateNoSupportWhileAcknowledgedCooldownRemains() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepare(data, caster, true); fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally.player, 8, true);
|
||||
var effects = effects(fixture, ally.player); effects.put(PotionEffectType.POISON, layered(PotionEffectType.POISON));
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler public void removal(EntityPotionEffectEvent event) {
|
||||
if (event.getEntity() == ally.player && event.getAction() == EntityPotionEffectEvent.Action.REMOVED) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
fixture.dispatcher.register(new Veto(), fixture.plugin); doReturn(false).when(caster.player).isSneaking();
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertTrue(effects.containsKey(PotionEffectType.POISON));
|
||||
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("purify"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void selfCleanseNeverEmitsSupportEvidence() {
|
||||
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
var parties = mock(games.dmg.fantasystick.party.PartyRoster.class); var player = mock(Player.class);
|
||||
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID());
|
||||
when(player.getUniqueId()).thenReturn(active.player()); when(player.isSneaking()).thenReturn(true);
|
||||
when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
|
||||
var poison = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
when(player.getPotionEffect(PotionEffectType.POISON)).thenAnswer(ignored -> poison.get()
|
||||
? new PotionEffect(PotionEffectType.POISON, 200, 0) : null);
|
||||
doAnswer(ignored -> { poison.set(false); return null; }).when(player).removePotionEffect(PotionEffectType.POISON);
|
||||
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
|
||||
when(input.player()).thenReturn(player); when(input.target()).thenReturn(player); when(input.session()).thenReturn(active);
|
||||
var supports = new ArrayList<games.dmg.fantasystick.ability.Purify.Support>();
|
||||
var purify = new games.dmg.fantasystick.ability.Purify(
|
||||
games.dmg.fantasystick.ability.Purify.Settings.defaults(), sessions, parties, supports::add);
|
||||
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
when(execution.stillCurrent()).thenReturn(true); when(execution.input()).thenReturn(input);
|
||||
when(execution.definition()).thenReturn(purify.definition());
|
||||
purify.activate(execution);
|
||||
assertFalse(poison.get()); assertTrue(supports.isEmpty()); verify(player).removePotionEffect(PotionEffectType.POISON);
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "purify-cooldown-seconds: 0\npurify-range-blocks: 16\n");
|
||||
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 definition = registry.find("purify").orElseThrow(); assertEquals(java.time.Duration.ZERO, definition.cooldown());
|
||||
assertEquals(0, new BigDecimal("16").compareTo(definition.parameters().get("range")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "purify-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "20"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("purify-cooldown-seconds", "purify-range-blocks"),
|
||||
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "purify-"}));
|
||||
assertEquals(List.of("12", "16"), fixture.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "purify-range-blocks", ""}));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks"});
|
||||
assertTrue(sender.messages.getLast().contains("=16"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "20"});
|
||||
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertEquals(0, new BigDecimal("20").compareTo(registry.find("purify").orElseThrow().parameters().get("range")));
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepare(data, caster, true); fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally.player, 19, true);
|
||||
effects(fixture, ally.player).put(PotionEffectType.SLOWNESS, instance(PotionEffectType.SLOWNESS, 200, 0));
|
||||
doReturn(false).when(caster.player).isSneaking(); use(fixture, caster); drain(fixture, 2);
|
||||
assertNull(ally.player.getPotionEffect(PotionEffectType.SLOWNESS));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "20", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
assertEquals(0, new BigDecimal("20").compareTo(reloaded.find("purify").orElseThrow().parameters().get("range")));
|
||||
}
|
||||
for (String invalid : List.of("purify-cooldown-seconds: -1\n", "purify-range-blocks: 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 fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true)); assertTrue(fixture.order.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final Map<Player, Map<PotionEffectType, MobEffectInstance>> EFFECTS = new java.util.WeakHashMap<>();
|
||||
|
||||
private static Map<PotionEffectType, MobEffectInstance> effects(FantasyStickLifecycleNativeTest.Fixture fixture, Player player) {
|
||||
var active = new HashMap<PotionEffectType, MobEffectInstance>(); EFFECTS.put(player, active);
|
||||
doAnswer(call -> {
|
||||
var effect = active.get(call.getArgument(0));
|
||||
return effect == null ? null : new PotionEffect(call.getArgument(0), effect.getDuration(), effect.getAmplifier());
|
||||
}).when(player).getPotionEffect(any());
|
||||
doAnswer(call -> {
|
||||
PotionEffectType type = call.getArgument(0); var current = active.get(type);
|
||||
if (current == null) return null;
|
||||
var old = new PotionEffect(type, current.getDuration(), current.getAmplifier());
|
||||
var event = new EntityPotionEffectEvent(player, old, null, null, EntityPotionEffectEvent.Cause.MILK,
|
||||
EntityPotionEffectEvent.Action.REMOVED, false);
|
||||
fixture.dispatcher.accept(event); if (!event.isCancelled()) active.remove(type); return null;
|
||||
}).when(player).removePotionEffect(any());
|
||||
return active;
|
||||
}
|
||||
|
||||
private static Map<PotionEffectType, MobEffectInstance> effectMap(Player player) {
|
||||
return EFFECTS.computeIfAbsent(player, ignored -> new HashMap<>());
|
||||
}
|
||||
|
||||
private static MobEffectInstance layered(PotionEffectType type) {
|
||||
var current = instance(type, 200, 0); current.update(instance(type, 40, 1));
|
||||
assertNotNull(current.hiddenEffect); return current;
|
||||
}
|
||||
|
||||
private static MobEffectInstance instance(PotionEffectType type, int duration, int amplifier) {
|
||||
return new MobEffectInstance(org.bukkit.craftbukkit.potion.CraftPotionEffectType.bukkitToMinecraftHolder(type), duration, amplifier);
|
||||
}
|
||||
|
||||
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, button(actor, "[Learn]", "purify")); drain(fixture, 2);
|
||||
}
|
||||
|
||||
private static void party(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor caster,
|
||||
FantasyStickLifecycleNativeTest.Actor ally) {
|
||||
var parties = PartyLifecycleNativeTest.parties(fixture); var group = parties.create(caster.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(ally.core.id, parties.invite(caster.core.id, group.id(), ally.core.id).orElseThrow().id()));
|
||||
}
|
||||
|
||||
private static void aim(FantasyStickLifecycleNativeTest.Actor caster, Player target, double distance, boolean visible) {
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
|
||||
doReturn(new Location(caster.world, distance, 64, 0)).when(target).getLocation();
|
||||
doReturn(visible).when(caster.player).hasLineOfSight(target); doReturn(target).when(caster.player).getTargetEntity(64);
|
||||
}
|
||||
|
||||
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 void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
|
||||
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom button(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 = button(page.raw(), label, ability); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + ability);
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom button(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 = button(child, label, ability); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void prepare(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
|
||||
stats.put(Stat.WIS, BigInteger.valueOf(14)); stats.put(Stat.CHR, BigInteger.TEN);
|
||||
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 ? Set.of("purify") : old.learned(), learned ? List.of("purify", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.OutOfCombatRecovery;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class QuickerRegenerationIINativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void prerequisiteLearnStackTransferTailReviveAndDependencySafeRefundUseTwoPassiveSlots() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"character-swap-cooldown-seconds: 0\nout-of-combat-healing-delay-seconds: 0\n"
|
||||
+ "out-of-combat-healing-interval-seconds: 10\nout-of-combat-healing-amount: 1\n"
|
||||
+ "quicker-regeneration-i-bonus-healing: 1\nquicker-regeneration-ii-bonus-healing: 2\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, a, BigDecimal.valueOf(4)); f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
|
||||
String initial = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(initial.contains("QuickerRegenerationIIQuickerRegenerationI,CON16,WIS14"), initial);
|
||||
assertFalse(hasAction(a, "[Learn]", "quicker_regeneration_ii"));
|
||||
|
||||
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "quicker_regeneration_i")); drain(f);
|
||||
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "quicker_regeneration_ii")); drain(f);
|
||||
var learned = storage(f).find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(List.of("quicker_regeneration_i", "quicker_regeneration_ii", ""), learned.passives());
|
||||
assertEquals(BigInteger.valueOf(38), learned.unspentPoints());
|
||||
assertEquals(a.core.record.loadout(), learned.loadout());
|
||||
|
||||
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
|
||||
recovery.sweep(); PartyBookNativeTest.drain(f);
|
||||
assertEquals(8, a.player.getHealth(), "The single pulse is base 1 + Regeneration I 1 + Regeneration II 2");
|
||||
learned = storage(f).find(learned.id()).get().orElseThrow(); assertEquals(0, new BigDecimal("8").compareTo(learned.health()));
|
||||
|
||||
PartyBookNativeTest.click(f, a, action(a, "[Unlearn]", "quicker_regeneration_i")); drain(f);
|
||||
var refused = storage(f).find(learned.id()).get().orElseThrow();
|
||||
assertEquals(learned.passives(), refused.passives()); assertEquals(learned.unspentPoints(), refused.unspentPoints());
|
||||
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
b.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(refused));
|
||||
f.players.put(b.core.id, b.player); f.activate(b); PartyBookNativeTest.open(f, b);
|
||||
assertTrue(PartyBookNativeTest.text(b).replaceAll("\\s+", "").contains("2:QuickerRegenerationII"));
|
||||
|
||||
b.core.inventory.setItemInOffHand(null); f.changed(b); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(b);
|
||||
var exited = storage(f).find(refused.id()).get().orElseThrow();
|
||||
var dead = storage(f).recordDeath(exited.id(), exited.revision()).get().orElseThrow();
|
||||
assertEquals(refused.passives(), dead.passives());
|
||||
var revived = storage(f).restoreDeathRecordAdministratively(dead.id(), dead.deathId(), new BigDecimal("1024")).get().orElseThrow();
|
||||
assertEquals(refused.passives(), revived.passives());
|
||||
var c = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
c.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(revived));
|
||||
f.players.put(c.core.id, c.player); f.activate(c); PartyBookNativeTest.open(f, c);
|
||||
PartyBookNativeTest.click(f, c, action(c, "[Unlearn]", "quicker_regeneration_ii")); drain(f);
|
||||
PartyBookNativeTest.click(f, c, action(c, "[Unlearn]", "quicker_regeneration_i")); drain(f);
|
||||
var refunded = storage(f).find(revived.id()).get().orElseThrow();
|
||||
assertEquals(List.of("", "", ""), refunded.passives()); assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
assertEquals(refunded.passives(), storage(f).find(refunded.id()).get().orElseThrow().passives());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void orphanedSavedSecondRankRemainsVisibleButCannotApplyWithoutItsPrerequisite() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 10\n"
|
||||
+ "out-of-combat-healing-amount: 1\nquicker-regeneration-i-bonus-healing: 1\n"
|
||||
+ "quicker-regeneration-ii-bonus-healing: 2\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, a, BigDecimal.valueOf(4), List.of("quicker_regeneration_ii", "", ""));
|
||||
f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
|
||||
assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("1:QuickerRegenerationII"));
|
||||
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
|
||||
recovery.sweep(); PartyBookNativeTest.drain(f);
|
||||
assertEquals(5, a.player.getHealth(), "An orphaned second rank cannot execute without Regeneration I");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configurationIsPermissionAwareDurableAndPrefixFiltered() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("quicker-regeneration-ii-bonus-healing"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii"}));
|
||||
assertEquals(List.of("2"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "quicker-regeneration-ii-bonus-healing", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii-bonus-healing"});
|
||||
assertTrue(sender.messages.getLast().contains("=2")); sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii-bonus-healing", "5"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii-bonus-healing", "5"});
|
||||
var callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var settings = (games.dmg.fantasystick.persistence.QuickerRegenerationIISettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "quickerRegenerationIISettings");
|
||||
assertEquals("5", settings.value());
|
||||
}
|
||||
}
|
||||
|
||||
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
|
||||
makeEligible(data, actor, health, List.of("", "", ""));
|
||||
}
|
||||
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health,
|
||||
List<String> passives) 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.CON, BigInteger.valueOf(16)); stats.put(Stat.WIS, BigInteger.valueOf(14));
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), BigInteger.valueOf(40), health, old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static boolean hasAction(FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
|
||||
try { action(actor, label, passive); return true; } catch (AssertionError missing) { return false; }
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content);
|
||||
for (var page : content.pages()) { var found = action(page.raw(), label, passive); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + passive);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
net.minecraft.network.chat.Component component, String label, String passive) {
|
||||
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(":" + passive)) return custom;
|
||||
for (var child : component.getSiblings()) { var found = action(child, label, passive); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.OutOfCombatRecovery;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class QuickerRegenerationNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void connectedBookLearnRecoveryTransferTailReloadAndUnlearnPathUsesSeparatePassiveSlots() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"character-swap-cooldown-seconds: 0\nout-of-combat-healing-delay-seconds: 0\n"
|
||||
+ "out-of-combat-healing-interval-seconds: 10\nout-of-combat-healing-amount: 1\n"
|
||||
+ "quicker-regeneration-i-bonus-healing: 3\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); makeEligible(data, a, BigDecimal.valueOf(4), List.of("", "", ""));
|
||||
f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
|
||||
var pages = nativePages(a);
|
||||
int active = first(pages, "Active abilities"), passive = first(pages, "Passive abilities");
|
||||
int activeIndex = first(pages, "Ability index"), passiveIndex = first(pages, "Passive index");
|
||||
int party = first(pages, "Party");
|
||||
assertTrue(active > 0 && active < passive && passive < activeIndex && activeIndex < passiveIndex && passiveIndex < party);
|
||||
assertTrue(pages.get(passive).getString().contains("1: No Passive"));
|
||||
assertTrue(pages.get(passiveIndex).getString().replace("\n", "").contains("Quicker Regeneration I"));
|
||||
var jump = changePageTo(pages.get(passiveIndex), pages, "Quicker Regeneration I"); assertNotNull(jump);
|
||||
String detail = pages.get(jump.page() - 1).getString();
|
||||
assertTrue(detail.replaceAll("\\s+", "").startsWith("QuickerRegenerationICON12,WIS12[Learn]"), detail);
|
||||
assertTrue(detail.replaceAll("\\s+", "").contains("out-of-combatrecoverypulse"));
|
||||
|
||||
var beforeLoadout = storage(f).find(a.core.record.id()).get().orElseThrow().loadout();
|
||||
PartyBookNativeTest.click(f, a, custom(List.of(pages.get(jump.page() - 1)), "fantasystick:passive_unlock"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var learned = storage(f).find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(List.of("quicker_regeneration_i", "", ""), learned.passives());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
|
||||
assertEquals(beforeLoadout, learned.loadout(), "Passive acquisition never consumes an active slot");
|
||||
|
||||
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
|
||||
recovery.sweep(); PartyBookNativeTest.drain(f);
|
||||
assertEquals(8, a.player.getHealth(), "Base one plus configured passive three is one native persisted pulse");
|
||||
var healed = storage(f).find(a.core.record.id()).get().orElseThrow(); assertEquals(0, new BigDecimal("8").compareTo(healed.health()));
|
||||
|
||||
var dead = storage(f).recordDeath(healed.id(), healed.revision()).get().orElseThrow();
|
||||
var tailText = text(new games.dmg.fantasystick.items.TailBooks().create(dead));
|
||||
assertTrue(tailText.replaceAll("\\s+", "").contains("Passive:quicker_regeneration_i"));
|
||||
var revived = storage(f).restoreDeathRecordAdministratively(dead.id(), dead.deathId(), new BigDecimal("1024")).get().orElseThrow();
|
||||
assertEquals(healed.passives(), revived.passives());
|
||||
|
||||
// Reissue the current same-ID sheet to model the supported transfer after revival.
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(revived));
|
||||
f.players.put(b.core.id, b.player); f.activate(b); PartyBookNativeTest.open(f, b);
|
||||
assertTrue(PartyBookNativeTest.text(b).replace("\n", "").contains("Quicker Regeneration I"));
|
||||
PartyBookNativeTest.click(f, b, custom(nativePages(b), "fantasystick:passive_unlearn"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage(f).find(revived.id()).get().orElseThrow();
|
||||
assertEquals(List.of("", "", ""), refunded.passives()); assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
|
||||
assertEquals(beforeLoadout, refunded.loadout());
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
assertEquals(refunded.passives(), storage(f).find(refunded.id()).get().orElseThrow().passives());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unknownFullPassiveSlotsRemainVisibleAndRefuseBeforeSpending() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, a, BigDecimal.valueOf(4), List.of("unknown_a", "unknown_b", "unknown_c"));
|
||||
f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
|
||||
String rendered = PartyBookNativeTest.text(a);
|
||||
for (String id : List.of("unknown_a", "unknown_b", "unknown_c")) assertTrue(rendered.contains(id));
|
||||
assertTrue(rendered.replaceAll("\\s+", "").contains("Unavailable:passiveslots"));
|
||||
var before = storage(f).find(a.core.record.id()).get().orElseThrow();
|
||||
assertFalse(hasCustom(nativePages(a), "fantasystick:passive_unlock"));
|
||||
assertEquals(before, storage(f).find(before.id()).get().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void liveBonusConfigurationIsPermissionAwareDurableAndFuturePulseOnly() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("quicker-regeneration-i-bonus-healing", "quicker-regeneration-ii-bonus-healing"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quicker"}));
|
||||
assertEquals(List.of("1"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "quicker-regeneration-i-bonus-healing", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-i-bonus-healing"});
|
||||
assertTrue(sender.messages.getLast().contains("=1"));
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quicker"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-i-bonus-healing", "4"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-i-bonus-healing", "4"});
|
||||
var callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-i-bonus-healing", "1025"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var settings = (games.dmg.fantasystick.persistence.QuickerRegenerationSettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "quickerRegenerationSettings");
|
||||
assertEquals("4", settings.value());
|
||||
}
|
||||
}
|
||||
|
||||
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health,
|
||||
List<String> passives) 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.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(40), health, old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static List<net.minecraft.network.chat.Component> nativePages(FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
return org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT).pages().stream().map(page -> page.raw()).toList();
|
||||
}
|
||||
private static int first(List<net.minecraft.network.chat.Component> pages, String text) {
|
||||
for (int i = 0; i < pages.size(); i++) if (pages.get(i).getString().startsWith(text)) return i;
|
||||
return -1;
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.ChangePage changePageTo(
|
||||
net.minecraft.network.chat.Component component, List<net.minecraft.network.chat.Component> pages, String heading) {
|
||||
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page
|
||||
&& page.page() > 0 && page.page() <= pages.size()
|
||||
&& pages.get(page.page() - 1).getString().replaceAll("\\s+", "")
|
||||
.startsWith(heading.replaceAll("\\s+", ""))) return page;
|
||||
for (var child : component.getSiblings()) {
|
||||
var found = changePageTo(child, pages, heading); if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom custom(List<net.minecraft.network.chat.Component> pages, String id) {
|
||||
for (var page : pages) { var found = custom(page, id); if (found != null) return found; }
|
||||
throw new AssertionError("Missing custom action " + id);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom custom(net.minecraft.network.chat.Component component, String id) {
|
||||
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom action
|
||||
&& action.id().toString().equals(id)) return action;
|
||||
for (var child : component.getSiblings()) { var found = custom(child, id); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
private static boolean hasCustom(List<net.minecraft.network.chat.Component> pages, String id) {
|
||||
return pages.stream().anyMatch(page -> hasCustom(page, id));
|
||||
}
|
||||
private static boolean hasCustom(net.minecraft.network.chat.Component component, String id) {
|
||||
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom
|
||||
&& custom.id().toString().equals(id)) return true;
|
||||
return component.getSiblings().stream().anyMatch(child -> hasCustom(child, id));
|
||||
}
|
||||
private static String text(org.bukkit.inventory.ItemStack book) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(book)
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
return content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,9 @@ class QuickstepNativeTest {
|
||||
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"));
|
||||
assertFalse(text.contains("Cost:")); 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());
|
||||
|
||||
@@ -247,8 +245,6 @@ class QuickstepNativeTest {
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
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.ability.AbilityExecution;
|
||||
import games.dmg.fantasystick.ability.OutgoingDamageBonus;
|
||||
import games.dmg.fantasystick.ability.RallyingCry;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
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.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class RallyingCryNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedUseBuffsCasterAndNearbyPartyAllyAndRewardsActualUse() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true); var caster = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, false);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); nearby(caster, List.of(ally.player));
|
||||
var casterEffects = effects(caster.player); var allyEffects = effects(ally.player);
|
||||
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("RallyingCry")); assertTrue(text.contains("CHR16")); assertTrue(text.contains("CON12"));
|
||||
assertFalse(text.contains("Cost:")); assertTrue(text.contains("20%")); assertTrue(text.contains("6seconds"));
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertEquals(0, casterEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
assertEquals(0, allyEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("rallying_cry"));
|
||||
|
||||
var enemy = mock(LivingEntity.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(enemy.getLocation()).thenReturn(new Location(caster.world, 4, 64, 0)); when(caster.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(ally.player).withDirectEntity(ally.player).build();
|
||||
var hit = new EntityDamageByEntityEvent(ally.player, enemy, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
fixture.dispatcher.accept(hit); assertEquals(12, hit.getDamage(), 1.0e-9);
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
fixture.delayed.getLast().run();
|
||||
assertFalse(casterEffects.containsKey(PotionEffectType.SPEED)); assertFalse(allyEffects.containsKey(PotionEffectType.SPEED));
|
||||
var expired = new EntityDamageByEntityEvent(ally.player, enemy, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
fixture.dispatcher.accept(expired); assertEquals(10, expired.getDamage(), 1.0e-9);
|
||||
var deadline = storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("rallying_cry");
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
var unlearn = button(caster, "[Unlearn]", "rallying_cry");
|
||||
PartyBookNativeTest.click(fixture, caster, unlearn); PartyBookNativeTest.click(fixture, caster, unlearn); drain(fixture, 2);
|
||||
assertEquals(deadline, storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("rallying_cry"));
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
|
||||
use(fixture, caster); assertTrue(caster.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void dynamicSphereMembershipSessionAndSourceValidityRecheckOwnedBenefits() throws Exception {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class);
|
||||
var bonus = mock(OutgoingDamageBonus.class); var sourceGrant = mock(AutoCloseable.class); var allyGrant = mock(AutoCloseable.class);
|
||||
when(bonus.grant(any(), any(), any(), any())).thenReturn(sourceGrant, allyGrant);
|
||||
var source = mock(Player.class); var ally = mock(Player.class); var outsider = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); var sourceId = UUID.randomUUID(); var allyId = UUID.randomUUID(); var outsiderId = UUID.randomUUID();
|
||||
when(source.getUniqueId()).thenReturn(sourceId); when(ally.getUniqueId()).thenReturn(allyId); when(outsider.getUniqueId()).thenReturn(outsiderId);
|
||||
when(source.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(ally.getLocation()).thenReturn(new Location(world, 7, 64, 0));
|
||||
when(outsider.getLocation()).thenReturn(new Location(world, 2, 64, 0));
|
||||
when(source.getNearbyEntities(8, 8, 8)).thenReturn(List.of(ally, outsider));
|
||||
var sourceEffects = effects(source); var allyEffects = effects(ally); effects(outsider);
|
||||
var sourceActive = new EquipmentSessions.Active(sourceId, UUID.randomUUID(), UUID.randomUUID());
|
||||
var allyActive = new EquipmentSessions.Active(allyId, UUID.randomUUID(), UUID.randomUUID());
|
||||
var outsiderActive = new EquipmentSessions.Active(outsiderId, UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(sourceId)).thenReturn(java.util.Optional.of(sourceActive)); when(sessions.current(sourceActive)).thenReturn(true);
|
||||
when(sessions.active(allyId)).thenReturn(java.util.Optional.of(allyActive)); when(sessions.current(allyActive)).thenReturn(true);
|
||||
when(sessions.active(outsiderId)).thenReturn(java.util.Optional.of(outsiderActive)); when(sessions.current(outsiderActive)).thenReturn(true);
|
||||
var party = new PartyRoster.Party(UUID.randomUUID(), sourceId, List.of(sourceId, allyId));
|
||||
when(parties.partyOf(sourceId)).thenReturn(java.util.Optional.of(party)); when(parties.partyOf(allyId)).thenReturn(java.util.Optional.of(party));
|
||||
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
|
||||
when(input.player()).thenReturn(source); when(input.session()).thenReturn(sourceActive);
|
||||
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
|
||||
var rally = new RallyingCry(RallyingCry.Settings.defaults(), sessions, parties, bonus); when(execution.definition()).thenReturn(rally.definition());
|
||||
rally.activate(execution);
|
||||
assertTrue(sourceEffects.containsKey(PotionEffectType.SPEED)); assertTrue(allyEffects.containsKey(PotionEffectType.SPEED));
|
||||
verify(bonus, times(2)).grant(eq(execution), any(), eq(new BigDecimal("20")), any());
|
||||
|
||||
when(ally.getLocation()).thenReturn(new Location(world, 9, 64, 0)); rally.sweep();
|
||||
assertFalse(allyEffects.containsKey(PotionEffectType.SPEED)); verify(allyGrant).close();
|
||||
when(ally.getLocation()).thenReturn(new Location(world, 7, 64, 0)); rally.sweep();
|
||||
assertTrue(allyEffects.containsKey(PotionEffectType.SPEED), "A valid party ally re-entering the active cry is rechecked");
|
||||
when(parties.partyOf(allyId)).thenReturn(java.util.Optional.empty()); rally.sweep();
|
||||
assertFalse(allyEffects.containsKey(PotionEffectType.SPEED));
|
||||
when(parties.partyOf(allyId)).thenReturn(java.util.Optional.of(party)); rally.sweep();
|
||||
assertTrue(allyEffects.containsKey(PotionEffectType.SPEED));
|
||||
when(sessions.current(allyActive)).thenReturn(false); rally.sweep();
|
||||
assertFalse(allyEffects.containsKey(PotionEffectType.SPEED));
|
||||
when(sessions.current(sourceActive)).thenReturn(false); rally.sweep(); verify(execution).finish(); verify(sourceGrant).close();
|
||||
}
|
||||
|
||||
@Test void duplicateRalliesDoNotStackSpeedOrDamageAndPreserveStrongerUnrelatedSpeed() throws Exception {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class);
|
||||
var supports = new ArrayList<OutgoingDamageBonus.Support>(); var bonus = new OutgoingDamageBonus(sessions, parties, supports::add);
|
||||
var sourceA = mock(Player.class); var sourceB = mock(Player.class); var ally = mock(Player.class); var enemy = mock(LivingEntity.class);
|
||||
var world = mock(org.bukkit.World.class); UUID a = UUID.randomUUID(), b = UUID.randomUUID(), c = UUID.randomUUID();
|
||||
when(sourceA.getUniqueId()).thenReturn(a); when(sourceB.getUniqueId()).thenReturn(b); when(ally.getUniqueId()).thenReturn(c);
|
||||
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
for (var player : List.of(sourceA, sourceB, ally)) when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
when(sourceA.getNearbyEntities(8, 8, 8)).thenReturn(List.of(sourceB, ally));
|
||||
when(sourceB.getNearbyEntities(8, 8, 8)).thenReturn(List.of(sourceA, ally));
|
||||
effects(sourceA); effects(sourceB); var allyEffects = effects(ally);
|
||||
allyEffects.put(PotionEffectType.SPEED, new PotionEffect(PotionEffectType.SPEED, 200, 1));
|
||||
var aa = new EquipmentSessions.Active(a, UUID.randomUUID(), UUID.randomUUID());
|
||||
var ba = new EquipmentSessions.Active(b, UUID.randomUUID(), UUID.randomUUID());
|
||||
var ca = new EquipmentSessions.Active(c, UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(a)).thenReturn(java.util.Optional.of(aa)); when(sessions.active(b)).thenReturn(java.util.Optional.of(ba));
|
||||
when(sessions.active(c)).thenReturn(java.util.Optional.of(ca)); when(sessions.current(any())).thenReturn(true);
|
||||
var party = new PartyRoster.Party(UUID.randomUUID(), a, List.of(a, b, c));
|
||||
when(parties.partyOf(a)).thenReturn(java.util.Optional.of(party)); when(parties.partyOf(b)).thenReturn(java.util.Optional.of(party));
|
||||
when(parties.partyOf(c)).thenReturn(java.util.Optional.of(party));
|
||||
var rally = new RallyingCry(RallyingCry.Settings.defaults(), sessions, parties, bonus);
|
||||
var first = execution(sourceA, aa, rally); var second = execution(sourceB, ba, rally); rally.activate(first); rally.activate(second);
|
||||
var hit = hit(ally, enemy, 10); bonus.increase(hit); assertEquals(12, hit.getDamage(), 1.0e-9);
|
||||
assertEquals(1, allyEffects.get(PotionEffectType.SPEED).getAmplifier(), "Stronger unrelated Speed is untouched");
|
||||
rally.close(); bonus.close(); assertEquals(1, allyEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"rallying-cry-cooldown-seconds: 2\nrallying-cry-radius-blocks: 10\nrallying-cry-duration-seconds: 8\n"
|
||||
+ "rallying-cry-speed-amplifier: 1\nrallying-cry-damage-bonus-percent: 30\n");
|
||||
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 definition = registry.find("rallying_cry").orElseThrow(); assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
|
||||
assertEquals(0, new BigDecimal("10").compareTo(definition.parameters().get("radius")));
|
||||
assertEquals(0, new BigDecimal("8").compareTo(definition.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("1"), definition.parameters().get("speed-amplifier"));
|
||||
assertEquals(new BigDecimal("30"), definition.parameters().get("damage-bonus-percent"));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "rallying-cry-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-radius-blocks", "12"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("rallying-cry-cooldown-seconds", "rallying-cry-damage-bonus-percent",
|
||||
"rallying-cry-duration-seconds", "rallying-cry-radius-blocks", "rallying-cry-speed-amplifier"),
|
||||
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "rallying-cry-"}));
|
||||
assertEquals(List.of("10", "8"), fixture.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "rallying-cry-radius-blocks", ""}));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-radius-blocks"});
|
||||
assertTrue(sender.messages.getLast().contains("=10"));
|
||||
for (String[] update : List.of(new String[]{"rallying-cry-cooldown-seconds", "3"},
|
||||
new String[]{"rallying-cry-radius-blocks", "12"}, new String[]{"rallying-cry-duration-seconds", "9"},
|
||||
new String[]{"rallying-cry-speed-amplifier", "2"}, new String[]{"rallying-cry-damage-bonus-percent", "40"})) {
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", update[0], update[1]});
|
||||
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
}
|
||||
var configured = registry.find("rallying_cry").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(3), configured.cooldown());
|
||||
assertEquals(0, new BigDecimal("12").compareTo(configured.parameters().get("radius")));
|
||||
assertEquals(0, new BigDecimal("9").compareTo(configured.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("2"), configured.parameters().get("speed-amplifier"));
|
||||
assertEquals(new BigDecimal("40"), configured.parameters().get("damage-bonus-percent"));
|
||||
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareCaster(data, caster, true); fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); nearby(caster, List.of(ally.player));
|
||||
var casterEffects = effects(caster.player); var allyEffects = effects(ally.player); use(fixture, caster); drain(fixture, 2);
|
||||
assertEquals(2, casterEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
assertEquals(2, allyEffects.get(PotionEffectType.SPEED).getAmplifier()); assertEquals(180L, fixture.delayedTicks.getLast());
|
||||
var futureTarget = mock(LivingEntity.class); when(futureTarget.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var future = hit(ally.player, futureTarget, 10); fixture.dispatcher.accept(future);
|
||||
assertEquals(14, future.getDamage(), 1.0e-9);
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-duration-seconds", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-radius-blocks", "12", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var saved = reloaded.find("rallying_cry").orElseThrow(); assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
|
||||
assertEquals(new BigDecimal("12"), saved.parameters().get("radius")); assertEquals(new BigDecimal("40"), saved.parameters().get("damage-bonus-percent"));
|
||||
assertEquals(0, new BigDecimal("9").compareTo(saved.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("2"), saved.parameters().get("speed-amplifier"));
|
||||
}
|
||||
for (String invalid : List.of("rallying-cry-cooldown-seconds: -1\n", "rallying-cry-radius-blocks: 0\n",
|
||||
"rallying-cry-duration-seconds: 0\n", "rallying-cry-speed-amplifier: -1\n",
|
||||
"rallying-cry-damage-bonus-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 fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true)); assertTrue(fixture.order.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static AbilityExecution execution(Player player, EquipmentSessions.Active active, RallyingCry rally) {
|
||||
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
|
||||
when(input.player()).thenReturn(player); when(input.session()).thenReturn(active);
|
||||
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true); when(execution.definition()).thenReturn(rally.definition());
|
||||
return execution;
|
||||
}
|
||||
private static Map<org.bukkit.potion.PotionEffectType, PotionEffect> effects(Player player) {
|
||||
var effects = new HashMap<org.bukkit.potion.PotionEffectType, PotionEffect>();
|
||||
doAnswer(call -> effects.get(call.getArgument(0))).when(player).getPotionEffect(any());
|
||||
doAnswer(call -> { PotionEffect effect = call.getArgument(0); effects.put(effect.getType(), effect); return true; })
|
||||
.when(player).addPotionEffect(any(), eq(true));
|
||||
doAnswer(call -> { effects.remove(call.getArgument(0)); return null; }).when(player).removePotionEffect(any()); return effects;
|
||||
}
|
||||
private static void nearby(FantasyStickLifecycleNativeTest.Actor source, List<? extends org.bukkit.entity.Entity> entities) {
|
||||
doReturn(new Location(source.world, 0, 64, 0)).when(source.player).getLocation();
|
||||
doReturn(new ArrayList<>(entities)).when(source.player).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
|
||||
for (var entity : entities) if (entity instanceof Player player) doReturn(new Location(source.world, 4, 64, 0)).when(player).getLocation();
|
||||
}
|
||||
private static EntityDamageByEntityEvent hit(Player attacker, LivingEntity target, double amount) {
|
||||
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(attacker).withDirectEntity(attacker).build();
|
||||
return new EntityDamageByEntityEvent(attacker, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, amount);
|
||||
}
|
||||
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 PartyRoster.Party party(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor source,
|
||||
FantasyStickLifecycleNativeTest.Actor target) {
|
||||
var parties = PartyLifecycleNativeTest.parties(fixture); var group = parties.create(source.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(target.core.id, parties.invite(source.core.id, group.id(), target.core.id).orElseThrow().id())); return group;
|
||||
}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
|
||||
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
}
|
||||
private static ClickEvent.Custom button(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 = button(page.raw(), label, ability); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + ability);
|
||||
}
|
||||
private static ClickEvent.Custom button(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 = button(child, label, ability); if (found != null) return found; } return null;
|
||||
}
|
||||
private static void prepareCaster(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
|
||||
stats.put(Stat.CHR, BigInteger.valueOf(16)); stats.put(Stat.CON, 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), old.health(),
|
||||
learned ? Set.of("rallying_cry") : old.learned(), learned ? List.of("rallying_cry", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,6 @@ class ReviveNativeTest {
|
||||
assertTrue(text.contains("CON12")); assertTrue(text.contains("Cooldown:86400s"));
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "revive"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "revive"));
|
||||
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();
|
||||
@@ -298,8 +296,6 @@ class ReviveNativeTest {
|
||||
|
||||
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));
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
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.ability.*;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
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.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.*;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class SanctuaryNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedAreaHealingCreatesPersistedSupportReward() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true); var caster = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data,caster,false); setHealth(data,ally,BigDecimal.valueOf(4));
|
||||
fixture.players.put(caster.core.id,caster.player);fixture.players.put(ally.core.id,ally.player);fixture.activate(caster);fixture.activate(ally);party(fixture,caster,ally);
|
||||
caster.core.location=new Location(caster.world,0,64,0);ally.core.location=new Location(caster.world,2,64,0);
|
||||
doReturn(List.of(ally.player)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(fixture,caster);
|
||||
String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Sanctuary"));assertTrue(text.contains("WIS24"));assertTrue(text.contains("CHR20"));assertTrue(text.contains("CON10"));
|
||||
PartyBookNativeTest.click(fixture,caster,button(caster,"[Learn]","sanctuary"));drain(fixture,2);
|
||||
use(fixture,caster);drain(fixture,2);assertEquals(2L,fixture.delayedTicks.getLast());
|
||||
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);assertEquals(6,ally.core.health,0);
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(caster.world.getUID()).thenReturn(UUID.randomUUID());when(enemy.getLocation()).thenReturn(new Location(caster.world,8,64,0));
|
||||
var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();
|
||||
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(fixture,4);
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"characters");
|
||||
assertEquals(BigInteger.valueOf(5),storage.find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5),storage.find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void fixedCylinderHasNoImmediatePulseThenSixOwnedHealingAndMitigationPulses() {
|
||||
var fixture = directFixture(); fixture.sanctuary.activate(fixture.execution);
|
||||
assertEquals(10, fixture.allyHealth.get(), 0); assertEquals(1, fixture.scheduled.size());
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class);
|
||||
var source = DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
fixture.mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 1.0e-9); fixture.mitigation.accepted(hit);
|
||||
verify(fixture.execution).report(eq(AbilityReport.Type.PREVENTION), eq(fixture.ally), eq(BigDecimal.valueOf(2.0)));
|
||||
run(fixture.scheduled, 9); assertEquals(10, fixture.allyHealth.get(), 0);
|
||||
run(fixture.scheduled, 1); assertEquals(12, fixture.allyHealth.get(), 0); assertEquals(1, fixture.supports.size());
|
||||
run(fixture.scheduled, 50); assertEquals(20, fixture.allyHealth.get(), 0, "Six two-point pulses cap at maximum health");
|
||||
assertEquals(5, fixture.supports.size(), "The sixth overhealing pulse creates no support evidence");
|
||||
verify(fixture.execution, atLeastOnce()).finish();
|
||||
}
|
||||
|
||||
@Test void exactHorizontalAndVerticalCylinderBoundaryIsIncludedButOutsideIsRemoved() {
|
||||
var fixture=directFixture();fixture.allyLocation.set(new Location(fixture.world,6,67,0));fixture.sanctuary.activate(fixture.execution);
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);var source=DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var boundary=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,fixture.ally,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);
|
||||
fixture.mitigation.reduce(boundary);assertEquals(8,boundary.getDamage(),0);
|
||||
fixture.allyLocation.set(new Location(fixture.world,6.01,67,0));run(fixture.scheduled,1);
|
||||
var outside=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,fixture.ally,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);
|
||||
fixture.mitigation.reduce(outside);assertEquals(10,outside.getDamage(),0);
|
||||
}
|
||||
|
||||
@Test void leavingCylinderOrObstructionEndsBenefitsAndSourceInvalidationCleansUp() {
|
||||
var fixture = directFixture(); fixture.sanctuary.activate(fixture.execution); run(fixture.scheduled, 10);
|
||||
doReturn(mock(org.bukkit.util.RayTraceResult.class)).when(fixture.world).rayTraceBlocks(any(Location.class),
|
||||
any(org.bukkit.util.Vector.class), anyDouble(), eq(org.bukkit.FluidCollisionMode.NEVER), eq(true));
|
||||
run(fixture.scheduled, 1);
|
||||
fixture.allyLocation.set(new Location(fixture.world, 2, 68, 0));
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class); var source = DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
|
||||
.withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
fixture.mitigation.reduce(hit); assertEquals(10, hit.getDamage(), 0);
|
||||
when(fixture.execution.stillCurrent()).thenReturn(false); fixture.scheduled.removeFirst().run(); verify(fixture.execution).finish();
|
||||
}
|
||||
|
||||
@Test void overlappingSanctuariesUseEarliestSourceForOnePulseAndStrongestReduction() {
|
||||
var fixture = directFixture(); var second = execution(fixture.source, fixture.sourceActive);
|
||||
when(second.definition()).thenReturn(fixture.sanctuary.definition()); var character = fixture.execution.character();
|
||||
when(second.character()).thenReturn(character);
|
||||
doAnswer(call->{fixture.scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);})
|
||||
.when(second).later(any(),any());
|
||||
fixture.sanctuary.activate(fixture.execution); fixture.sanctuary.activate(second);
|
||||
for (int i = 0; i < 20; i++) fixture.scheduled.removeFirst().run();
|
||||
assertEquals(12, fixture.allyHealth.get(), 0); assertEquals(1, fixture.supports.size(), "Overlap supplies one healing pulse");
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class); var damageSource = DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
|
||||
.withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally, EntityDamageEvent.DamageCause.ENTITY_ATTACK, damageSource, 10);
|
||||
fixture.mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 0, "Two sanctuaries do not multiply reduction");
|
||||
fixture.mitigation.accepted(hit); verify(fixture.execution).report(eq(AbilityReport.Type.PREVENTION), eq(fixture.ally), any());
|
||||
verify(second, never()).report(eq(AbilityReport.Type.PREVENTION), any(), any());
|
||||
var stone = mock(AbilityExecution.class); when(stone.stillCurrent()).thenReturn(true);
|
||||
fixture.mitigation.grant(stone, fixture.ally, BigDecimal.valueOf(50));
|
||||
var stronger = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, damageSource, 10);
|
||||
fixture.mitigation.reduce(stronger); assertEquals(5, stronger.getDamage(), 0, "Stronger Stone Skin-style mitigation supersedes Sanctuary");
|
||||
}
|
||||
|
||||
@Test void definitionAndPluginConfigurationExposeAllApprovedValues() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "sanctuary-cooldown-seconds: 30\nsanctuary-radius-blocks: 7\n"
|
||||
+ "sanctuary-vertical-range-blocks: 4\nsanctuary-duration-seconds: 4\nsanctuary-pulse-interval-seconds: 1\n"
|
||||
+ "sanctuary-healing-per-pulse: 3\nsanctuary-damage-reduction-percent: 25\n");
|
||||
try (var plugin = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
plugin.plugin.setEnabled(true); var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(plugin.plugin, "abilityRegistry");
|
||||
var definition = registry.find("sanctuary").orElseThrow(); assertEquals(org.bukkit.Material.SEA_LANTERN, definition.icon());
|
||||
assertEquals(BigInteger.valueOf(24), definition.requirements().get(Stat.WIS)); assertEquals(BigInteger.valueOf(20), definition.requirements().get(Stat.CHR));
|
||||
assertEquals(BigInteger.TEN, definition.requirements().get(Stat.CON)); assertEquals(java.time.Duration.ofSeconds(30), definition.cooldown());
|
||||
assertEquals(new BigDecimal("3"), definition.parameters().get("healing-per-pulse"));
|
||||
assertEquals(new BigDecimal("25"), definition.parameters().get("damage-reduction-percent"));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(plugin.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sanctuary-"}).isEmpty());
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","8"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("sanctuary-cooldown-seconds","sanctuary-damage-reduction-percent","sanctuary-duration-seconds",
|
||||
"sanctuary-healing-per-pulse","sanctuary-pulse-interval-seconds","sanctuary-radius-blocks","sanctuary-vertical-range-blocks"),
|
||||
plugin.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sanctuary-"}));
|
||||
assertEquals(List.of("2","3"),plugin.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sanctuary-healing-per-pulse",""}));
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-healing-per-pulse"});assertTrue(sender.messages.getLast().contains("=3"));
|
||||
for(String[] update:List.of(new String[]{"sanctuary-cooldown-seconds","20"},new String[]{"sanctuary-radius-blocks","8"},
|
||||
new String[]{"sanctuary-vertical-range-blocks","5"},new String[]{"sanctuary-duration-seconds","5"},
|
||||
new String[]{"sanctuary-pulse-interval-seconds","1"},new String[]{"sanctuary-healing-per-pulse","4"},
|
||||
new String[]{"sanctuary-damage-reduction-percent","30"})){
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=plugin.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
|
||||
var configured=registry.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),configured.parameters().get("healing-per-pulse"));
|
||||
assertEquals(new BigDecimal("30"),configured.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),configured.cooldown());
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","0"});assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","8","extra"});assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
plugin.plugin.setEnabled(false);plugin.plugin.setEnabled(true);
|
||||
var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(plugin.plugin,"abilityRegistry");
|
||||
var saved=reloaded.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),saved.parameters().get("healing-per-pulse"));
|
||||
assertEquals(new BigDecimal("30"),saved.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),saved.cooldown());
|
||||
}
|
||||
}
|
||||
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,FantasyStickLifecycleNativeTest.Actor actor){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));}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture,int count)throws Exception{for(int i=0;i<count;i++)PartyBookNativeTest.drain(fixture);}
|
||||
private static void party(FantasyStickLifecycleNativeTest.Fixture fixture,FantasyStickLifecycleNativeTest.Actor source,FantasyStickLifecycleNativeTest.Actor target){var parties=PartyLifecycleNativeTest.parties(fixture);var group=parties.create(source.core.id).orElseThrow();assertTrue(parties.accept(target.core.id,parties.invite(source.core.id,group.id(),target.core.id).orElseThrow().id()));}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom button(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=button(page.raw(),label,ability);if(found!=null)return found;}throw new AssertionError("Missing "+label);}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom button(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=button(child,label,ability);if(found!=null)return found;}return null;}
|
||||
private static void prepareCaster(Path directory,FantasyStickLifecycleNativeTest.Actor actor,boolean learned)throws Exception{java.nio.file.Files.createDirectories(directory);var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(directory);var old=repository.load(actor.core.record.id()).orElseThrow();var stats=new EnumMap<>(old.stats());stats.put(Stat.WIS,BigInteger.valueOf(24));stats.put(Stat.CHR,BigInteger.valueOf(20));stats.put(Stat.CON,BigInteger.TEN);repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),stats,old.level(),old.experience(),BigInteger.valueOf(learned?0:1),old.health(),learned?Set.of("sanctuary"):old.learned(),learned?List.of("sanctuary","","","","","",""):old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());}
|
||||
private static void setHealth(Path directory,FantasyStickLifecycleNativeTest.Actor actor,BigDecimal health)throws Exception{var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(directory);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(),health,old.learned(),old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());actor.core.health=health.doubleValue();}
|
||||
|
||||
private static void run(ArrayDeque<Runnable> scheduled,int count){for(int i=0;i<count;i++){assertFalse(scheduled.isEmpty());scheduled.removeFirst().run();}}
|
||||
|
||||
private DirectFixture directFixture() {
|
||||
var source=mock(Player.class);var ally=mock(Player.class);var world=mock(org.bukkit.World.class);
|
||||
UUID sourceId=UUID.randomUUID(),allyId=UUID.randomUUID();when(source.getUniqueId()).thenReturn(sourceId);when(ally.getUniqueId()).thenReturn(allyId);
|
||||
var sourceLocation=new AtomicReference<>(new Location(world,0,64,0));var allyLocation=new AtomicReference<>(new Location(world,2,64,0));
|
||||
when(source.getLocation()).thenAnswer(call->sourceLocation.get().clone());when(ally.getLocation()).thenAnswer(call->allyLocation.get().clone());
|
||||
when(source.getBoundingBox()).thenAnswer(call->box(sourceLocation.get()));when(ally.getBoundingBox()).thenAnswer(call->box(allyLocation.get()));
|
||||
when(source.getWorld()).thenReturn(world);when(ally.getWorld()).thenReturn(world);when(world.getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble())).thenReturn(List.of(ally));
|
||||
var sourceHealth=new AtomicReference<>(20.0);var allyHealth=new AtomicReference<>(10.0);health(source,sourceHealth);health(ally,allyHealth);
|
||||
var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var characters=mock(CharacterStorage.class);
|
||||
var sourceActive=new EquipmentSessions.Active(sourceId,UUID.randomUUID(),UUID.randomUUID());var allyActive=new EquipmentSessions.Active(allyId,UUID.randomUUID(),UUID.randomUUID());
|
||||
when(sessions.active(sourceId)).thenReturn(Optional.of(sourceActive));when(sessions.active(allyId)).thenReturn(Optional.of(allyActive));when(sessions.current(any())).thenReturn(true);
|
||||
var records=new HashMap<UUID,AtomicReference<CharacterRecord>>();records.put(sourceId,new AtomicReference<>(record(sourceActive.character(),BigDecimal.valueOf(20))));records.put(allyId,new AtomicReference<>(record(allyActive.character(),BigDecimal.TEN)));
|
||||
when(sessions.currentRecord(sourceActive)).thenAnswer(call->Optional.of(records.get(sourceId).get()));when(sessions.currentRecord(allyActive)).thenAnswer(call->Optional.of(records.get(allyId).get()));
|
||||
when(sessions.acknowledgeProgress(any(),any(),any())).thenAnswer(call->{EquipmentSessions.Active active=call.getArgument(0);CharacterRecord saved=call.getArgument(2);records.get(active.player()).set(saved);return true;});
|
||||
when(characters.saveHealth(any(),any(),any(),any())).thenAnswer(call->{UUID character=call.getArgument(0);BigInteger expected=call.getArgument(1);BigDecimal health=call.getArgument(2);
|
||||
var entry=records.values().stream().filter(ref->ref.get().id().equals(character)).findFirst().orElseThrow();var old=entry.get();
|
||||
if(!old.revision().equals(expected))return CompletableFuture.completedFuture(Optional.empty());
|
||||
var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),health,old.learned(),old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null);
|
||||
return CompletableFuture.completedFuture(Optional.of(saved));});
|
||||
var party=new PartyRoster.Party(UUID.randomUUID(),sourceId,List.of(sourceId,allyId));when(parties.partyOf(sourceId)).thenReturn(Optional.of(party));when(parties.partyOf(allyId)).thenReturn(Optional.of(party));
|
||||
var mitigation=new CombatMitigation();var scheduled=new ArrayDeque<Runnable>();var supports=new ArrayList<MendingLight.Support>();
|
||||
var sanctuary=new Sanctuary(Sanctuary.Settings.defaults(),characters,sessions,parties,mitigation,Runnable::run,event->{},()->BigDecimal.valueOf(Double.MAX_VALUE),supports::add);
|
||||
var execution=execution(source,sourceActive);when(execution.definition()).thenReturn(sanctuary.definition());
|
||||
var character=records.get(sourceId).get();when(execution.character()).thenReturn(character);
|
||||
doAnswer(call->{scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);}).when(execution).later(any(),any());
|
||||
return new DirectFixture(source,ally,world,sourceActive,allyLocation,allyHealth,execution,sanctuary,mitigation,scheduled,supports);
|
||||
}
|
||||
private record DirectFixture(Player source,Player ally,org.bukkit.World world,EquipmentSessions.Active sourceActive,
|
||||
AtomicReference<Location> allyLocation,AtomicReference<Double> allyHealth,AbilityExecution execution,Sanctuary sanctuary,
|
||||
CombatMitigation mitigation,ArrayDeque<Runnable> scheduled,List<MendingLight.Support> supports){}
|
||||
private static AbilityExecution execution(Player source,EquipmentSessions.Active active){var execution=mock(AbilityExecution.class);var input=mock(ControlInput.class);when(input.player()).thenReturn(source);when(input.session()).thenReturn(active);when(execution.input()).thenReturn(input);when(execution.stillCurrent()).thenReturn(true);return execution;}
|
||||
private static CharacterRecord record(UUID id,BigDecimal health){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.TEN);return new CharacterRecord(id,base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),health,Set.of("sanctuary"),List.of("sanctuary","","","","","",""),base.cooldowns(),base.restrictedKills(),false,null,null);}
|
||||
private static BoundingBox box(Location at){return new BoundingBox(at.getX()-.3,at.getY(),at.getZ()-.3,at.getX()+.3,at.getY()+1.8,at.getZ()+.3);}
|
||||
private static void health(Player player,AtomicReference<Double> health){var attribute=mock(AttributeInstance.class);when(attribute.getValue()).thenReturn(20.0);when(player.getAttribute(Attribute.MAX_HEALTH)).thenReturn(attribute);when(player.getHealth()).thenAnswer(call->health.get());doAnswer(call->{health.set(call.getArgument(0));return null;}).when(player).setHealth(anyDouble());}
|
||||
}
|
||||
@@ -34,8 +34,6 @@ class SecondWindNativeTest {
|
||||
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");
|
||||
@@ -173,8 +171,6 @@ class SecondWindNativeTest {
|
||||
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,
|
||||
|
||||
@@ -48,6 +48,7 @@ class SessionControlsNativeTest {
|
||||
item.getItemMeta().displayName());
|
||||
assertEquals(1, item.getAmount());
|
||||
assertEquals(1, item.getMaxStackSize());
|
||||
assertNull(item.getItemMeta().getItemModel(), "Book, basic attack and No Ability remain unaffected");
|
||||
var expected = new SessionControls.Identity(owner, session, slot);
|
||||
assertEquals(expected, controls.identify(item).orElseThrow());
|
||||
assertTrue(controls.owned(item, owner, session));
|
||||
@@ -82,6 +83,7 @@ class SessionControlsNativeTest {
|
||||
var item = roundTrip(items[abilitySlot + 2]);
|
||||
assertEquals(materials.get(index), item.getType(), ids.get(index));
|
||||
assertEquals(Component.text(names.get(index)), item.getItemMeta().displayName());
|
||||
assertNull(item.getItemMeta().getItemModel(), "Ready ability icons remain unaffected");
|
||||
assertEquals(ids.get(index), item.getItemMeta().getPersistentDataContainer()
|
||||
.get(key("control_ability_id"), PersistentDataType.STRING));
|
||||
assertEquals(1, item.getAmount());
|
||||
@@ -94,6 +96,45 @@ class SessionControlsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void futureCharacterCooldownsUseContainedPanesWhileElapsedAndUnknownControlsKeepTheirPresentation() {
|
||||
var now = java.time.Instant.parse("2000-01-01T00:00:00Z");
|
||||
var clock = java.time.Clock.fixed(now, java.time.ZoneOffset.UTC);
|
||||
var timed = new SessionControls(new games.dmg.fantasystick.ability.AbilityRegistry(), clock);
|
||||
var base = character(List.of("fireball", "fireball", "revive", "removed_spell", "", "", ""));
|
||||
var record = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
|
||||
base.experience(), base.unspentPoints(), base.health(), base.learned(), base.loadout(),
|
||||
Map.of("fireball", now.plusSeconds(5), "revive", now, "removed_spell", now.plusSeconds(5)),
|
||||
base.dead(), base.deathId(), base.diedAt());
|
||||
|
||||
var items = timed.create(record, owner, session);
|
||||
for (int slot : List.of(2, 3)) {
|
||||
var pane = roundTrip(items[slot]);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, pane.getType());
|
||||
assertEquals(Component.text("Fireball — Cooling Down"), pane.getItemMeta().displayName());
|
||||
assertEquals("cooldown", pane.getPersistentDataContainer().get(key("control_kind"), PersistentDataType.STRING));
|
||||
assertEquals(java.util.Optional.of("fireball"), timed.ability(pane));
|
||||
assertEquals(new SessionControls.Identity(owner, session, slot), timed.identify(pane).orElseThrow());
|
||||
assertEquals(SessionControls.COOLDOWN_MODEL, pane.getItemMeta().getItemModel());
|
||||
}
|
||||
assertEquals(Material.ECHO_SHARD, items[4].getType(), "A deadline equal to now is ready");
|
||||
assertNull(items[4].getItemMeta().getItemModel(), "An exact-deadline ready icon retains no stale model");
|
||||
assertEquals(Material.PAPER, items[5].getType(), "Unknown saved IDs remain unavailable, not cooling panes");
|
||||
assertNull(items[5].getItemMeta().getItemModel(), "Unknown controls remain visible and unaffected");
|
||||
|
||||
var genuineWithoutModel = items[2].clone();
|
||||
var genuineMeta = genuineWithoutModel.getItemMeta();
|
||||
genuineMeta.setItemModel(null);
|
||||
genuineWithoutModel.setItemMeta(genuineMeta);
|
||||
assertEquals(java.util.Optional.of("fireball"), timed.ability(genuineWithoutModel),
|
||||
"Cosmetic model metadata is not casting or session authority");
|
||||
var ordinaryPane = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
|
||||
var ordinaryMeta = ordinaryPane.getItemMeta();
|
||||
ordinaryMeta.setItemModel(SessionControls.COOLDOWN_MODEL);
|
||||
ordinaryPane.setItemMeta(ordinaryMeta);
|
||||
assertRejected(ordinaryPane);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSavedAbilitiesRemainUnavailableWithoutChangingTheRecordOrCallerLoadout() {
|
||||
var loadout = new ArrayList<>(List.of("fireball", "removed_spell", "", "revive", "", "", ""));
|
||||
|
||||
@@ -32,11 +32,9 @@ class ShieldBashNativeTest {
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(book.contains("ShieldBash")); assertTrue(book.contains("STR12")); assertTrue(book.contains("CON12"));
|
||||
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("3")); assertTrue(book.contains("6s"));
|
||||
assertFalse(book.contains("Cost:")); assertTrue(book.contains("3")); assertTrue(book.contains("6s"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "shield_bash"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "shield_bash"));
|
||||
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());
|
||||
assertEquals(org.bukkit.Material.SHIELD, a.core.inventory.getItem(2).getType());
|
||||
@@ -298,8 +296,6 @@ class ShieldBashNativeTest {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "shield_bash"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "shield_bash"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
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.ability.AbilityExecution;
|
||||
import games.dmg.fantasystick.ability.OutgoingDamageBonus;
|
||||
import games.dmg.fantasystick.ability.RallyingCry;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.passive.SpeedI;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class SpeedINativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void connectedLearnExitTransferUnlearnAndDefeatOwnOnlyTheCharacterSpeedLayer() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); withDex(data, a, List.of("", "", ""));
|
||||
var aEffects = effects(a.player); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var speed = (SpeedI) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "speedI");
|
||||
PartyBookNativeTest.open(f, a); assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("SpeedIDEX14[Learn]"));
|
||||
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "speed_i")); settle(f); speed.sweep();
|
||||
var learned = record(f, a); assertEquals(List.of("speed_i", "", ""), learned.passives());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints()); assertEquals(0, aEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone(); a.core.inventory.setItemInOffHand(null);
|
||||
f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a); speed.sweep();
|
||||
assertFalse(aEffects.containsKey(PotionEffectType.SPEED));
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); var bEffects = effects(b.player);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b); speed.sweep();
|
||||
assertEquals(0, bEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
PartyBookNativeTest.open(f, b); PartyBookNativeTest.click(f, b, action(b, "[Unlearn]", "speed_i")); settle(f); speed.sweep();
|
||||
var unlearned = storage(f).find(learned.id()).get().orElseThrow(); assertFalse(unlearned.passives().contains("speed_i"));
|
||||
assertEquals(learned.unspentPoints().add(BigInteger.ONE), unlearned.unspentPoints()); assertFalse(bEffects.containsKey(PotionEffectType.SPEED));
|
||||
PartyBookNativeTest.open(f, b); Runnable failed;
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters").resolve(".records.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid()); PartyBookNativeTest.click(f, b, action(b, "[Learn]", "speed_i"));
|
||||
failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed);
|
||||
}
|
||||
failed.run(); speed.sweep(); assertFalse(bEffects.containsKey(PotionEffectType.SPEED));
|
||||
assertFalse(storage(f).find(learned.id()).get().orElseThrow().passives().contains("speed_i"));
|
||||
b.core.inventory.setItemInOffHand(null); f.changed(b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
storage(f).find(learned.id()).get(); f.receipts().find(b.core.id).toCompletableFuture().get();
|
||||
|
||||
withDex(data, a, List.of("speed_i", "", ""));
|
||||
var c = new FantasyStickLifecycleNativeTest.Actor(data); var cEffects = effects(c.player);
|
||||
c.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(
|
||||
new YamlCharacterRepository(data).load(a.core.record.id()).orElseThrow()));
|
||||
f.players.put(c.core.id, c.player); f.activate(c); speed.sweep(); assertTrue(cEffects.containsKey(PotionEffectType.SPEED));
|
||||
c.player.setHealth(1);
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(c.player, mock(org.bukkit.damage.DamageSource.class),
|
||||
new java.util.ArrayList<>(), 0, net.kyori.adventure.text.Component.text("defeat"), true);
|
||||
f.dispatcher.accept(death); settle(f); speed.sweep(); assertFalse(cEffects.containsKey(PotionEffectType.SPEED));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void milkAndExpiredEffectsReapplyWhileStrongerAndCleanupPathsPreserveUnrelatedSpeed() {
|
||||
var sessions = mock(EquipmentSessions.class); var player = mock(Player.class); var effects = effects(player);
|
||||
UUID playerId = UUID.randomUUID(); var active = new EquipmentSessions.Active(playerId, UUID.randomUUID(), UUID.randomUUID());
|
||||
var learned = mock(CharacterRecord.class); when(learned.passives()).thenReturn(List.of("speed_i", "", "")); when(learned.dead()).thenReturn(false);
|
||||
when(player.getUniqueId()).thenReturn(playerId); when(sessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(active, learned)));
|
||||
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(learned));
|
||||
when(sessions.currentPlayer(active)).thenReturn(java.util.Optional.of(player));
|
||||
var speed = new SpeedI(sessions); assertEquals(Map.of(Stat.DEX, BigInteger.valueOf(14)), speed.definition().requirements());
|
||||
speed.sweep(); assertEquals(0, effects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
speed.sweep(); verify(player, times(1)).addPotionEffect(any(), eq(true));
|
||||
effects.clear(); speed.sweep(); verify(player, times(2)).addPotionEffect(any(), eq(true));
|
||||
|
||||
var stronger = new PotionEffect(PotionEffectType.SPEED, 200, 1); effects.put(PotionEffectType.SPEED, stronger); speed.sweep();
|
||||
assertSame(stronger, effects.get(PotionEffectType.SPEED));
|
||||
when(learned.passives()).thenReturn(List.of("", "", "")); speed.sweep();
|
||||
assertSame(stronger, effects.get(PotionEffectType.SPEED), "Cleanup cannot remove a stronger unrelated effect");
|
||||
when(learned.passives()).thenReturn(List.of("speed_i", "", "")); effects.clear(); speed.sweep();
|
||||
assertEquals(0, effects.get(PotionEffectType.SPEED).getAmplifier(), "The passive resumes after stronger Speed expires");
|
||||
speed.close(); assertFalse(effects.containsKey(PotionEffectType.SPEED));
|
||||
|
||||
var equalSessions = mock(EquipmentSessions.class); var equalPlayer = mock(Player.class); var equalEffects = effects(equalPlayer);
|
||||
UUID equalId = UUID.randomUUID(); var equalActive = new EquipmentSessions.Active(equalId, UUID.randomUUID(), UUID.randomUUID());
|
||||
var equalRecord = mock(CharacterRecord.class); when(equalRecord.passives()).thenReturn(List.of("speed_i", "", ""));
|
||||
when(equalPlayer.getUniqueId()).thenReturn(equalId); when(equalSessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(equalActive, equalRecord)));
|
||||
when(equalSessions.current(equalActive)).thenReturn(true); when(equalSessions.currentRecord(equalActive)).thenReturn(java.util.Optional.of(equalRecord));
|
||||
when(equalSessions.currentPlayer(equalActive)).thenReturn(java.util.Optional.of(equalPlayer));
|
||||
var equal = new PotionEffect(PotionEffectType.SPEED, 40, 0); equalEffects.put(PotionEffectType.SPEED, equal);
|
||||
var equalSpeed = new SpeedI(equalSessions); equalSpeed.sweep(); assertSame(equal, equalEffects.get(PotionEffectType.SPEED));
|
||||
equalEffects.clear(); equalSpeed.sweep(); assertTrue(equalEffects.get(PotionEffectType.SPEED).isInfinite()); equalSpeed.close();
|
||||
|
||||
var weaker = new PotionEffect(PotionEffectType.SPEED, 4, -1); equalEffects.put(PotionEffectType.SPEED, weaker);
|
||||
var weakerSpeed = new SpeedI(equalSessions); weakerSpeed.sweep();
|
||||
assertEquals(0, equalEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
when(equalRecord.passives()).thenReturn(List.of("", "", "")); weakerSpeed.sweep();
|
||||
assertEquals(-1, equalEffects.get(PotionEffectType.SPEED).getAmplifier(), "An unexpired weaker native layer is preserved");
|
||||
weakerSpeed.close();
|
||||
|
||||
when(equalRecord.passives()).thenReturn(List.of("speed_i", "", ""));
|
||||
equalEffects.put(PotionEffectType.SPEED, new PotionEffect(PotionEffectType.SPEED, 1, -1));
|
||||
var expiredWeak = new SpeedI(equalSessions); expiredWeak.sweep(); expiredWeak.sweep();
|
||||
when(equalRecord.passives()).thenReturn(List.of("", "", "")); expiredWeak.sweep();
|
||||
assertFalse(equalEffects.containsKey(PotionEffectType.SPEED), "An unrelated weaker layer that expired is not restored");
|
||||
expiredWeak.close();
|
||||
}
|
||||
|
||||
@Test void rallyOverlapNeverStacksAndEitherOwnerLeavesTheOtherApplicableLayer() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var player = mock(Player.class);
|
||||
var effects = effects(player); UUID id = UUID.randomUUID(); var active = new EquipmentSessions.Active(id, UUID.randomUUID(), UUID.randomUUID());
|
||||
var record = mock(CharacterRecord.class); when(record.passives()).thenReturn(List.of("speed_i", "", ""));
|
||||
when(player.getUniqueId()).thenReturn(id); when(player.getLocation()).thenReturn(new Location(mock(org.bukkit.World.class), 0, 64, 0));
|
||||
when(player.getNearbyEntities(anyDouble(), anyDouble(), anyDouble())).thenReturn(List.of());
|
||||
when(sessions.active(id)).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
|
||||
when(sessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(active, record)));
|
||||
when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record)); when(sessions.currentPlayer(active)).thenReturn(java.util.Optional.of(player));
|
||||
var speed = new SpeedI(sessions); speed.sweep();
|
||||
var rally = new RallyingCry(RallyingCry.Settings.defaults(), sessions, parties,
|
||||
new OutgoingDamageBonus(sessions, parties, ignored -> { }));
|
||||
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
|
||||
when(input.player()).thenReturn(player); when(input.session()).thenReturn(active); when(execution.input()).thenReturn(input);
|
||||
when(execution.stillCurrent()).thenReturn(true); when(execution.definition()).thenReturn(rally.definition());
|
||||
rally.activate(execution); assertEquals(0, effects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
rally.close(); assertTrue(effects.containsKey(PotionEffectType.SPEED), "Rally cleanup leaves the passive-owned Speed I");
|
||||
speed.close(); assertFalse(effects.containsKey(PotionEffectType.SPEED));
|
||||
|
||||
var rallyFirst = new RallyingCry(RallyingCry.Settings.defaults(), sessions, parties,
|
||||
new OutgoingDamageBonus(sessions, parties, ignored -> { }));
|
||||
when(execution.definition()).thenReturn(rallyFirst.definition()); rallyFirst.activate(execution);
|
||||
assertTrue(effects.containsKey(PotionEffectType.SPEED));
|
||||
var passiveSecond = new SpeedI(sessions); passiveSecond.sweep(); rallyFirst.close();
|
||||
assertFalse(effects.containsKey(PotionEffectType.SPEED)); passiveSecond.sweep();
|
||||
assertTrue(effects.containsKey(PotionEffectType.SPEED), "The learned passive resumes after Rally-owned Speed I ends");
|
||||
passiveSecond.close();
|
||||
}
|
||||
|
||||
private static Map<PotionEffectType, PotionEffect> effects(Player player) {
|
||||
var result = new HashMap<PotionEffectType, PotionEffect>();
|
||||
doAnswer(call -> result.get(call.getArgument(0))).when(player).getPotionEffect(any());
|
||||
doAnswer(call -> { PotionEffect effect = call.getArgument(0); result.put(effect.getType(), effect); return true; })
|
||||
.when(player).addPotionEffect(any(), eq(true));
|
||||
doAnswer(call -> { result.remove(call.getArgument(0)); return null; }).when(player).removePotionEffect(any()); return result;
|
||||
}
|
||||
private static void withDex(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> passives) throws Exception {
|
||||
var repository = new YamlCharacterRepository(data); var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new EnumMap<>(old.stats()); stats.put(Stat.DEX, BigInteger.valueOf(14));
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(),
|
||||
old.unspentPoints(), old.health(), old.learned(), old.loadout(), passives, old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
return storage(f).find(actor.core.record.id()).get().orElseThrow();
|
||||
}
|
||||
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(content);
|
||||
for (var page : content.pages()) { var found = action(page.raw(), label, passive); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + passive);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
net.minecraft.network.chat.Component component, String label, String passive) {
|
||||
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(":" + passive)) return custom;
|
||||
for (var child : component.getSiblings()) { var found = action(child, label, passive); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -31,11 +31,9 @@ class StoneSkinNativeTest {
|
||||
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"));
|
||||
assertFalse(text.contains("Cost:")); 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);
|
||||
@@ -58,8 +56,6 @@ class StoneSkinNativeTest {
|
||||
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")));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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.ability.*;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.*;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.attribute.*;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.*;
|
||||
import org.bukkit.event.entity.*;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.util.RayTraceResult;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class WarBannerNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
|
||||
@Test void bookAcquisitionSolidTopPlacementBenefitsPartyAndRewardsUsedDamage()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);resistance.modifiers().add(new AttributeModifier(new NamespacedKey("test","unrelated_knockback"),.2,AttributeModifier.Operation.ADD_NUMBER));
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("WarBanner"));assertTrue(text.contains("STR22"));assertTrue(text.contains("CHR22"));assertTrue(text.contains("CON12"));assertTrue(text.contains("25%"));assertTrue(text.contains("50%"));
|
||||
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);use(f,caster);drain(f,2);assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
|
||||
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(caster.world,4,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();var hit=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
f.delayed.getLast().run();assertEquals(.2,resistance.value(),1e-9,"Owned cleanup preserves unrelated knockback modifiers");var expired=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidOrNonTopPlacementRefusesBeforeCooldown()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true,false);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));placement(caster,12,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));placement(caster);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));}
|
||||
}
|
||||
|
||||
@Test void fixedCylinderObstructionLifecycleOverlapAndRallyUseStrongestOnly()throws Exception{
|
||||
var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var supports=new ArrayList<OutgoingDamageBonus.Support>();var bonuses=new OutgoingDamageBonus(sessions,parties,supports::add);var source=mock(Player.class);var ally=mock(Player.class);var world=mock(World.class);UUID a=UUID.randomUUID(),b=UUID.randomUUID();when(source.getUniqueId()).thenReturn(a);when(ally.getUniqueId()).thenReturn(b);var sa=new EquipmentSessions.Active(a,UUID.randomUUID(),UUID.randomUUID());var ba=new EquipmentSessions.Active(b,UUID.randomUUID(),UUID.randomUUID());when(sessions.active(a)).thenReturn(Optional.of(sa));when(sessions.active(b)).thenReturn(Optional.of(ba));when(sessions.current(any())).thenReturn(true);var party=new PartyRoster.Party(UUID.randomUUID(),a,List.of(a,b));when(parties.partyOf(a)).thenReturn(Optional.of(party));when(parties.partyOf(b)).thenReturn(Optional.of(party));when(source.getWorld()).thenReturn(world);when(source.getLocation()).thenReturn(new Location(world,.5,65,.5));when(ally.getLocation()).thenReturn(new Location(world,8.5,69,.5));when(source.getBoundingBox()).thenReturn(new org.bukkit.util.BoundingBox(0,65,0,1,67,1));when(ally.getBoundingBox()).thenReturn(new org.bukkit.util.BoundingBox(8,69,0,9,71,1));doReturn(List.of(source,ally)).when(world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var sourceResistance=attribute(source);var allyResistance=attribute(ally);var block=mock(Block.class);when(block.getType()).thenReturn(Material.STONE);when(block.getLocation()).thenReturn(new Location(world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(BlockFace.UP);when(source.getEyeLocation()).thenReturn(new Location(world,0,65.62,0,0,45));doReturn(ray).when(world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(12d),eq(FluidCollisionMode.NEVER),eq(true));
|
||||
var banner=new WarBanner(WarBanner.Settings.defaults(),sessions,parties,bonuses);var be=execution(source,sa,banner.definition());var owned=new ArrayList<AutoCloseable>();doAnswer(call->{owned.add(call.getArgument(0));return null;}).when(be).own(any());banner.activate(be);assertEquals(.5,allyResistance.value(),1e-9);assertEquals(.5,sourceResistance.value(),1e-9);verify(block,never()).setType(any(Material.class));verify(world,atLeastOnce()).spawnParticle(eq(Particle.DUST),any(Location.class),eq(16),anyDouble(),anyDouble(),anyDouble(),eq(0d),any(Particle.DustOptions.class));
|
||||
when(source.getLocation()).thenReturn(new Location(world,100,65,.5));banner.sweep();assertEquals(0,sourceResistance.value(),1e-9);assertEquals(.5,allyResistance.value(),1e-9,"The fixed banner remains when its caster moves away");when(source.getLocation()).thenReturn(new Location(world,.5,65,.5));banner.sweep();
|
||||
when(ally.getLocation()).thenReturn(new Location(world,8.51,69,.5));banner.sweep();assertEquals(0,allyResistance.value(),1e-9);
|
||||
when(ally.getLocation()).thenReturn(new Location(world,4.5,65,.5));when(ally.getBoundingBox()).thenReturn(new org.bukkit.util.BoundingBox(4,65,0,5,67,1));banner.sweep();assertEquals(.5,allyResistance.value(),1e-9);when(parties.partyOf(b)).thenReturn(Optional.empty());banner.sweep();assertEquals(0,allyResistance.value(),1e-9);when(parties.partyOf(b)).thenReturn(Optional.of(party));banner.sweep();assertEquals(.5,allyResistance.value(),1e-9);
|
||||
var be2=execution(source,sa,banner.definition());var owned2=new ArrayList<AutoCloseable>();doAnswer(call->{owned2.add(call.getArgument(0));return null;}).when(be2).own(any());banner.activate(be2);assertEquals(.5,allyResistance.value(),1e-9,"Overlapping banners retain one resistance layer");var rally=new RallyingCry(RallyingCry.Settings.defaults(),sessions,parties,bonuses);when(source.getNearbyEntities(8,8,8)).thenReturn(List.of(ally));var re=execution(source,sa,rally.definition());var rallyOwned=new ArrayList<AutoCloseable>();doAnswer(call->{rallyOwned.add(call.getArgument(0));return null;}).when(re).own(any());rally.activate(re);
|
||||
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());var hit=hit(ally,enemy,10);bonuses.increase(hit);assertEquals(12.5,hit.getDamage(),1e-9,"Banner 25% supersedes rather than adds Rally 20%");bonuses.accepted(hit);verify(be).report(eq(AbilityReport.Type.BOOST_USED),eq(enemy),argThat(value->value.compareTo(new BigDecimal("2.5"))==0));verify(be2,never()).report(eq(AbilityReport.Type.BOOST_USED),any(),any());verify(re,never()).report(eq(AbilityReport.Type.BOOST_USED),any(),any());
|
||||
owned.getFirst().close();var fallback=hit(ally,enemy,10);bonuses.increase(fallback);assertEquals(12.5,fallback.getDamage(),1e-9);bonuses.accepted(fallback);verify(be2).report(eq(AbilityReport.Type.BOOST_USED),eq(enemy),any());assertEquals(.5,allyResistance.value(),1e-9);
|
||||
doReturn(ray).when(world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),anyDouble(),eq(FluidCollisionMode.NEVER),eq(true));banner.sweep();
|
||||
var rallyOnly=hit(ally,enemy,10);bonuses.increase(rallyOnly);assertEquals(12,rallyOnly.getDamage(),1e-9);assertEquals(0,allyResistance.value(),1e-9);owned2.getFirst().close();
|
||||
banner.close();rally.close();bonuses.close();
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRestartAndEverySetting()throws Exception{
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"war-banner-cooldown-seconds: 2\nwar-banner-placement-range-blocks: 10\nwar-banner-radius-blocks: 7\nwar-banner-vertical-range-blocks: 3\nwar-banner-duration-seconds: 8\nwar-banner-damage-bonus-percent: 30\nwar-banner-knockback-resistance-percent: 40\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var definition=registry.find("war_banner").orElseThrow();assertEquals(java.time.Duration.ofSeconds(2),definition.cooldown());assertEquals(0,new BigDecimal("10").compareTo(definition.parameters().get("placement-range")));assertEquals(new BigDecimal("30"),definition.parameters().get("damage-bonus-percent"));var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","war-banner-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","war-banner-radius-blocks","9"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(7,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","war-banner-"}).size());assertEquals(List.of("7","8"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,""}));f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS});assertTrue(sender.messages.getLast().contains("=7"));
|
||||
for(String[] update:List.of(new String[]{WarBannerSettings.COOLDOWN,"3"},new String[]{WarBannerSettings.PLACEMENT_RANGE,"11"},new String[]{WarBannerSettings.RADIUS,"9"},new String[]{WarBannerSettings.VERTICAL,"5"},new String[]{WarBannerSettings.DURATION,"9"},new String[]{WarBannerSettings.DAMAGE_BONUS,"35"},new String[]{WarBannerSettings.KNOCKBACK_RESISTANCE,"60"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
|
||||
var saved=registry.find("war_banner").orElseThrow();assertEquals(java.time.Duration.ofSeconds(3),saved.cooldown());assertEquals(new BigDecimal("35"),saved.parameters().get("damage-bonus-percent"));assertEquals(new BigDecimal("60"),saved.parameters().get("knockback-resistance-percent"));
|
||||
var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster,11);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);use(f,caster);drain(f,2);assertEquals(180L,f.delayedTicks.getLast());assertEquals(.6,resistance.value(),1e-9);var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());var hit=hit(ally.player,enemy,10);f.dispatcher.accept(hit);assertEquals(13.5,hit.getDamage(),1e-9);
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,"9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("35"),reloaded.find("war_banner").orElseThrow().parameters().get("damage-bonus-percent"));}
|
||||
}
|
||||
|
||||
private record AttributeFixture(Set<AttributeModifier> modifiers){double value(){return modifiers.stream().mapToDouble(AttributeModifier::getAmount).sum();}}
|
||||
private static AttributeFixture attribute(Player player){var instance=mock(AttributeInstance.class);var modifiers=new LinkedHashSet<AttributeModifier>();when(instance.getModifiers()).thenAnswer(call->Set.copyOf(modifiers));doAnswer(call->{modifiers.add(call.getArgument(0));return null;}).when(instance).addModifier(any());doAnswer(call->{modifiers.remove(call.getArgument(0));return null;}).when(instance).removeModifier(any(AttributeModifier.class));doReturn(instance).when(player).getAttribute(Attribute.KNOCKBACK_RESISTANCE);return new AttributeFixture(modifiers);}
|
||||
private static AbilityExecution execution(Player player,EquipmentSessions.Active active,AbilityDefinition definition){var e=mock(AbilityExecution.class);var input=mock(ControlInput.class);when(input.player()).thenReturn(player);when(input.session()).thenReturn(active);when(e.input()).thenReturn(input);when(e.stillCurrent()).thenReturn(true);when(e.definition()).thenReturn(definition);return e;}
|
||||
private static EntityDamageByEntityEvent hit(Player attacker,LivingEntity target,double amount){var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(attacker).withDirectEntity(attacker).build();return new EntityDamageByEntityEvent(attacker,target,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,amount);}
|
||||
private static void placement(FantasyStickLifecycleNativeTest.Actor actor){placement(actor,12,BlockFace.UP);}
|
||||
private static void placement(FantasyStickLifecycleNativeTest.Actor actor,double range){placement(actor,range,BlockFace.UP);}
|
||||
private static void placement(FantasyStickLifecycleNativeTest.Actor actor,double range,BlockFace face){var block=mock(Block.class);when(block.getType()).thenReturn(Material.STONE);when(block.getLocation()).thenReturn(new Location(actor.world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(face);doReturn(new Location(actor.world,0,65.62,0,0,45)).when(actor.player).getEyeLocation();doReturn(ray).when(actor.world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(range),eq(FluidCollisionMode.NEVER),eq(true));}
|
||||
private static void nearby(FantasyStickLifecycleNativeTest.Actor source,List<? extends Entity> entities){doReturn(new ArrayList<>(entities)).when(source.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());for(var entity:entities)if(entity instanceof Player p)doReturn(new Location(source.world,2,65,0)).when(p).getLocation();}
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,BlockFace.SELF,EquipmentSlot.HAND));}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture f,int count)throws Exception{for(int i=0;i<count;i++)PartyBookNativeTest.drain(f);}
|
||||
private static void party(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a,FantasyStickLifecycleNativeTest.Actor b){var parties=PartyLifecycleNativeTest.parties(f);var group=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,group.id(),b.core.id).orElseThrow().id()));}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f){return(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");}
|
||||
private static ClickEvent.Custom button(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=button(page.raw(),label,ability);if(found!=null)return found;}throw new AssertionError("Missing "+label);}
|
||||
private static ClickEvent.Custom button(net.minecraft.network.chat.Component c,String label,String ability){if(c.getString().equals(label)&&c.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:c.getSiblings()){var found=button(child,label,ability);if(found!=null)return found;}return null;}
|
||||
private static void prepare(Path directory,FantasyStickLifecycleNativeTest.Actor actor,boolean learned,boolean rally)throws Exception{java.nio.file.Files.createDirectories(directory);var repository=new YamlCharacterRepository(directory);var old=repository.load(actor.core.record.id()).orElseThrow();var stats=new EnumMap<>(old.stats());stats.put(Stat.STR,BigInteger.valueOf(22));stats.put(Stat.CHR,BigInteger.valueOf(22));stats.put(Stat.CON,BigInteger.valueOf(12));var learnedSet=new LinkedHashSet<String>();if(learned)learnedSet.add("war_banner");if(rally)learnedSet.add("rallying_cry");var slots=learned?List.of("war_banner","","","","","",""):old.loadout();repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),stats,old.level(),old.experience(),BigInteger.valueOf(learned?0:1),old.health(),learnedSet,slots,old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:select",
|
||||
"property": "minecraft:display_context",
|
||||
"cases": [
|
||||
{
|
||||
"when": [
|
||||
"firstperson_lefthand",
|
||||
"firstperson_righthand"
|
||||
],
|
||||
"model": {
|
||||
"type": "minecraft:empty"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fallback": {
|
||||
"type": "minecraft:model",
|
||||
"model": "minecraft:item/gray_stained_glass_pane"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pack": {
|
||||
"description": "Fantasy Stick ${version} sheet presentation",
|
||||
"description": "Fantasy Stick ${version} first-person presentation",
|
||||
"min_format": [88, 0],
|
||||
"max_format": [88, 0]
|
||||
}
|
||||
|
||||
@@ -86,6 +86,12 @@ class DistributionTest {
|
||||
assertTrue(defaults.contains("admin-revive-selection-timeout-seconds: 60"));
|
||||
assertTrue(defaults.contains("sheet-resource-pack-url: ''"));
|
||||
assertTrue(defaults.contains("sheet-resource-pack-sha1: ''"));
|
||||
for (String setting : java.util.List.of("out-of-combat-healing-delay-seconds: 30",
|
||||
"out-of-combat-healing-interval-seconds: 10", "out-of-combat-healing-amount: 1",
|
||||
"quicker-regeneration-i-bonus-healing: 1", "quicker-regeneration-ii-bonus-healing: 2",
|
||||
"health-steal-healing-per-kill: 1")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged recovery default: " + setting);
|
||||
}
|
||||
assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
|
||||
var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class");
|
||||
assertNotNull(entrypoint);
|
||||
@@ -100,7 +106,7 @@ class DistributionTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagesVersionedContextSensitiveSheetResourcePackAndMatchingRuntimeMetadata() throws Exception {
|
||||
@Test void packagesVersionedContextSensitiveResourcePackAndMatchingRuntimeMetadata() throws Exception {
|
||||
Path artifact = Path.of(System.getProperty("distribution.jar"));
|
||||
Path pack = Path.of(System.getProperty("resource.pack.zip"));
|
||||
String version = System.getProperty("distribution.version");
|
||||
@@ -123,6 +129,19 @@ class DistributionTest {
|
||||
assertTrue(json.contains("firstperson_righthand"));
|
||||
assertTrue(json.contains("minecraft:empty"));
|
||||
assertTrue(json.contains("minecraft:item/paper"), "Inventory and other contexts retain Paper presentation");
|
||||
var cooldownModel = zip.getEntry("assets/fantasystick/items/cooldown_control.json");
|
||||
assertNotNull(cooldownModel, "The single pack includes the dedicated cooldown-control model");
|
||||
String cooldownJson;
|
||||
try (var input = zip.getInputStream(cooldownModel)) {
|
||||
cooldownJson = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
assertTrue(cooldownJson.contains("minecraft:select"));
|
||||
assertTrue(cooldownJson.contains("minecraft:display_context"));
|
||||
assertTrue(cooldownJson.contains("firstperson_lefthand"));
|
||||
assertTrue(cooldownJson.contains("firstperson_righthand"));
|
||||
assertTrue(cooldownJson.contains("minecraft:empty"));
|
||||
assertTrue(cooldownJson.contains("minecraft:item/gray_stained_glass_pane"),
|
||||
"Hotbar, inventory and all other contexts retain gray-pane presentation");
|
||||
}
|
||||
try (var jar = new JarFile(artifact.toFile())) {
|
||||
var metadata = jar.getJarEntry("sheet-resource-pack.properties"); assertNotNull(metadata);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PassiveRegistryTest {
|
||||
@Test void rejectsDuplicateAndMissingDependenciesWithoutLosingRegisteredDefinition() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new PassiveDefinition("costly", "Costly", Map.of(), Set.of(),
|
||||
BigInteger.TWO, List.of("Effect")));
|
||||
var registry = new PassiveRegistry();
|
||||
var original = definition("first", Set.of()); registry.register(original);
|
||||
assertThrows(IllegalArgumentException.class, () -> registry.register(original));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> registry.register(definition("second", Set.of("missing"))));
|
||||
assertTrue(registry.find("second").isEmpty());
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> registry.replace(definition("first", Set.of("missing"))));
|
||||
assertEquals(original, registry.find("first").orElseThrow());
|
||||
}
|
||||
|
||||
private static PassiveDefinition definition(String id, Set<String> prerequisites) {
|
||||
return new PassiveDefinition(id, id, Map.of(), prerequisites, BigInteger.ONE, List.of("Effect"));
|
||||
}
|
||||
}
|
||||
@@ -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 ChainLightningSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void defaultsAndParsingRetainExactDecay() {
|
||||
var settings = ChainLightningSettings.parse(defaults());
|
||||
assertEquals(java.math.BigInteger.valueOf(8), settings.baseDamage());
|
||||
assertEquals(java.time.Duration.ofSeconds(18), settings.cooldown());
|
||||
assertEquals(16, settings.initialRange(), 0); assertEquals(6, settings.jumpRange(), 0);
|
||||
assertEquals(3, settings.additionalTargets());
|
||||
assertEquals(new java.math.BigDecimal("0.75"), settings.jumpMultiplier());
|
||||
}
|
||||
|
||||
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new ChainLightningSettings(data, defaults())) {
|
||||
var range = settings.set(ChainLightningSettings.JUMP_RANGE, "7").toCompletableFuture();
|
||||
var cooldown = settings.set(ChainLightningSettings.COOLDOWN, "24").toCompletableFuture();
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(6, settings.settings().jumpRange(), 0);
|
||||
settings.publish(savedCooldown); settings.publish(savedRange);
|
||||
assertEquals(7, settings.settings().jumpRange(), 0);
|
||||
assertEquals(java.time.Duration.ofSeconds(24), settings.settings().cooldown());
|
||||
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", yaml.get("unrelated"));
|
||||
assertEquals("7", String.valueOf(yaml.get(ChainLightningSettings.JUMP_RANGE)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
|
||||
try (var settings = new ChainLightningSettings(data, defaults())) {
|
||||
for (var entry : Map.of(
|
||||
ChainLightningSettings.COOLDOWN, "-1", ChainLightningSettings.INITIAL_RANGE, "0",
|
||||
ChainLightningSettings.JUMP_RANGE, "0", ChainLightningSettings.ADDITIONAL, "-1",
|
||||
ChainLightningSettings.BASE, "-1", ChainLightningSettings.MULTIPLIER, "0").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(ChainLightningSettings.COOLDOWN, "0.0000000001"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new ChainLightningSettings(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(ChainLightningSettings.JUMP_RANGE, "7").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("6", settings.value(ChainLightningSettings.JUMP_RANGE));
|
||||
}
|
||||
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||
var unsafe = data.resolve("unsafe"); Files.createDirectories(unsafe);
|
||||
var outside = data.resolve("outside.yml"); Files.writeString(outside, "safe\n");
|
||||
Files.createSymbolicLink(unsafe.resolve("config.yml"), outside);
|
||||
try (var settings = new ChainLightningSettings(unsafe, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(ChainLightningSettings.MULTIPLIER, "0.5").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(outside));
|
||||
}
|
||||
}
|
||||
var closed = new ChainLightningSettings(data.resolve("closed"), defaults()); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set(ChainLightningSettings.BASE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String, String> defaults() {
|
||||
var values = new LinkedHashMap<String, String>();
|
||||
for (String key : ChainLightningSettings.keys()) values.put(key, ChainLightningSettings.defaultValue(key));
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,38 @@ class CharacterStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void abilityUnlockFillsLowestEmptySlotAndRefusesAFullLoadoutAtomically() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var partlyOccupied = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
|
||||
base.experience(), BigInteger.TEN, base.health(), java.util.Set.of("known", "unknown_saved"),
|
||||
java.util.List.of("known", "", "unknown_saved", "", "", "", ""), base.cooldowns(),
|
||||
base.restrictedKills(), false, null, null);
|
||||
repository.create(partlyOccupied);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var learned = storage.unlockAbility(partlyOccupied.id(), partlyOccupied.revision(), "new_ability",
|
||||
java.util.Map.of(), BigInteger.ONE).join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, learned.status());
|
||||
var saved = learned.record().orElseThrow();
|
||||
assertEquals("new_ability", saved.loadout().get(1), "Learning must use the lowest empty slot");
|
||||
assertEquals("unknown_saved", saved.loadout().get(2), "Unknown saved assignments must not be overwritten");
|
||||
assertEquals(BigInteger.valueOf(9), saved.unspentPoints());
|
||||
}
|
||||
|
||||
var full = new CharacterRecord(UUID.randomUUID(), base.name(), BigInteger.ZERO, base.stats(), base.level(),
|
||||
base.experience(), BigInteger.TEN, base.health(), java.util.Set.of("a", "b", "c", "d", "e", "f", "g"),
|
||||
java.util.List.of("a", "b", "c", "d", "e", "f", "g"), base.cooldowns(), base.restrictedKills(), false, null, null);
|
||||
repository.create(full);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var refused = storage.unlockAbility(full.id(), full.revision(), "eighth", java.util.Map.of(), BigInteger.ONE).join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.LOADOUT_FULL, refused.status());
|
||||
assertEquals(full, refused.record().orElseThrow());
|
||||
assertEquals(full, repository.load(full.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void abilityUnlearnRefundsExactlyOnePointClearsEverySlotAndPreservesCooldown() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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 FrostNovaSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new FrostNovaSettings(data, defaults())) {
|
||||
var range = settings.set(FrostNovaSettings.RADIUS, "7").toCompletableFuture();
|
||||
var cooldown = settings.set(FrostNovaSettings.COOLDOWN, "24").toCompletableFuture();
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(5, settings.settings().radius(), 0);
|
||||
settings.publish(savedCooldown); settings.publish(savedRange);
|
||||
assertEquals(7, settings.settings().radius(), 0);
|
||||
assertEquals(java.time.Duration.ofSeconds(24), settings.settings().cooldown());
|
||||
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", yaml.get("unrelated"));
|
||||
assertEquals("7", String.valueOf(yaml.get(FrostNovaSettings.RADIUS)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
|
||||
try (var settings = new FrostNovaSettings(data, defaults())) {
|
||||
for (var entry : Map.of(
|
||||
FrostNovaSettings.COOLDOWN, "-1",
|
||||
FrostNovaSettings.RADIUS, "0",
|
||||
FrostNovaSettings.DURATION, "0",
|
||||
FrostNovaSettings.BASE, "-1",
|
||||
FrostNovaSettings.SLOWNESS, "-1").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(FrostNovaSettings.COOLDOWN, "0.0000000001"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new FrostNovaSettings(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(FrostNovaSettings.RADIUS, "7").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("5", settings.value(FrostNovaSettings.RADIUS));
|
||||
}
|
||||
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||
var unsafe = data.resolve("unsafe"); Files.createDirectories(unsafe);
|
||||
var outside = data.resolve("outside.yml"); Files.writeString(outside, "safe\n");
|
||||
Files.createSymbolicLink(unsafe.resolve("config.yml"), outside);
|
||||
try (var settings = new FrostNovaSettings(unsafe, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(FrostNovaSettings.DURATION, "8").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(outside));
|
||||
}
|
||||
}
|
||||
var closed = new FrostNovaSettings(data.resolve("closed"), defaults()); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set(FrostNovaSettings.BASE, "8").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String, String> defaults() {
|
||||
var values = new LinkedHashMap<String, String>();
|
||||
for (String key : FrostNovaSettings.keys()) values.put(key, FrostNovaSettings.defaultValue(key));
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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 GuardianBondSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new GuardianBondSettings(data, defaults())) {
|
||||
var range = settings.set(GuardianBondSettings.RANGE, "16").toCompletableFuture();
|
||||
var cooldown = settings.set(GuardianBondSettings.COOLDOWN, "24").toCompletableFuture();
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(10, settings.settings().range(), 0);
|
||||
settings.publish(savedCooldown); settings.publish(savedRange);
|
||||
assertEquals(16, settings.settings().range(), 0);
|
||||
assertEquals(java.time.Duration.ofSeconds(24), settings.settings().cooldown());
|
||||
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", yaml.get("unrelated"));
|
||||
assertEquals("16", String.valueOf(yaml.get(GuardianBondSettings.RANGE)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
|
||||
try (var settings = new GuardianBondSettings(data, defaults())) {
|
||||
for (var entry : Map.of(
|
||||
GuardianBondSettings.COOLDOWN, "-1",
|
||||
GuardianBondSettings.RANGE, "0",
|
||||
GuardianBondSettings.DURATION, "0",
|
||||
GuardianBondSettings.REDIRECT, "101").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(GuardianBondSettings.COOLDOWN, "0.0000000001"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new GuardianBondSettings(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(GuardianBondSettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("10", settings.value(GuardianBondSettings.RANGE));
|
||||
}
|
||||
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||
var unsafe = data.resolve("unsafe"); Files.createDirectories(unsafe);
|
||||
var outside = data.resolve("outside.yml"); Files.writeString(outside, "safe\n");
|
||||
Files.createSymbolicLink(unsafe.resolve("config.yml"), outside);
|
||||
try (var settings = new GuardianBondSettings(unsafe, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(GuardianBondSettings.DURATION, "8").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(outside));
|
||||
}
|
||||
}
|
||||
var closed = new GuardianBondSettings(data.resolve("closed"), defaults()); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set(GuardianBondSettings.REDIRECT, "40").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String, String> defaults() {
|
||||
var values = new LinkedHashMap<String, String>();
|
||||
for (String key : GuardianBondSettings.keys()) values.put(key, GuardianBondSettings.defaultValue(key));
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class HealthStealSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void validatesReviewedBoundsAndPersistsBeforePublication() throws Exception {
|
||||
assertEquals(java.math.BigInteger.ONE, HealthStealSettings.parse("1").healing());
|
||||
assertEquals(java.math.BigInteger.ZERO, HealthStealSettings.parse("0").healing());
|
||||
assertEquals(java.math.BigInteger.valueOf(1024), HealthStealSettings.parse("1024").healing());
|
||||
for (String invalid : java.util.List.of("-1", "1025", "1.5", "x")) {
|
||||
assertThrows(IllegalArgumentException.class, () -> HealthStealSettings.parse(invalid));
|
||||
}
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new HealthStealSettings(data, "1")) {
|
||||
var update = settings.set("4").toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals("1", settings.value()); assertTrue(settings.publish(update)); assertEquals("4", settings.value());
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", saved.get("unrelated")); assertEquals("4", String.valueOf(saved.get(HealthStealSettings.KEY)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedWriteAndCloseRetainTheEffectiveValue() throws Exception {
|
||||
try (var settings = new HealthStealSettings(data, "1", (from, to) -> { throw new java.io.IOException("simulated"); })) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set("4").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("1", settings.value());
|
||||
}
|
||||
var closed = new HealthStealSettings(data.resolve("closed"), "1"); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set("4").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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 MendingLightSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new MendingLightSettings(data, defaults())) {
|
||||
var range = settings.set(MendingLightSettings.RANGE, "16").toCompletableFuture();
|
||||
var base = settings.set(MendingLightSettings.BASE_HEALING, "8").toCompletableFuture();
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS); var savedBase = base.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(12, settings.settings().range(), 0);
|
||||
settings.publish(savedBase); settings.publish(savedRange);
|
||||
assertEquals(16, settings.settings().range(), 0);
|
||||
assertEquals(new java.math.BigInteger("8"), settings.settings().baseHealing());
|
||||
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", yaml.get("unrelated")); assertEquals("16", String.valueOf(yaml.get(MendingLightSettings.RANGE)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
|
||||
try (var settings = new MendingLightSettings(data, defaults())) {
|
||||
for (var entry : Map.of(MendingLightSettings.COOLDOWN, "-1", MendingLightSettings.RANGE, "0",
|
||||
MendingLightSettings.BASE_HEALING, "-1").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(MendingLightSettings.COOLDOWN, "0.0000000001"));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(MendingLightSettings.BASE_HEALING, "1.5"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new MendingLightSettings(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(MendingLightSettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("12", settings.value(MendingLightSettings.RANGE));
|
||||
}
|
||||
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||
var unsafe = data.resolve("unsafe"); Files.createDirectories(unsafe);
|
||||
var outside = data.resolve("outside.yml"); Files.writeString(outside, "safe\n");
|
||||
Files.createSymbolicLink(unsafe.resolve("config.yml"), outside);
|
||||
try (var settings = new MendingLightSettings(unsafe, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(MendingLightSettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(outside));
|
||||
}
|
||||
}
|
||||
var closed = new MendingLightSettings(data.resolve("closed"), defaults()); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set(MendingLightSettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String, String> defaults() {
|
||||
var values = new LinkedHashMap<String, String>();
|
||||
for (String key : MendingLightSettings.keys()) values.put(key, MendingLightSettings.defaultValue(key));
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.*;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class MeteorSettingsTest {
|
||||
@TempDir Path data;
|
||||
@Test void defaultsMatchApprovedPacket(){var s=MeteorSettings.parse(defaults());assertEquals(java.math.BigInteger.valueOf(14),s.baseDamage());assertEquals(Duration.ofSeconds(45),s.cooldown());assertEquals(24,s.targetRange());assertEquals(Duration.ofSeconds(2),s.warning());assertEquals(5,s.radius());assertEquals(4,s.verticalRange());}
|
||||
@Test void validatesEveryBound(){for(var values:List.of(changed(MeteorSettings.BASE_DAMAGE,"-1"),changed(MeteorSettings.COOLDOWN,"-1"),changed(MeteorSettings.TARGET_RANGE,"0"),changed(MeteorSettings.WARNING,"0"),changed(MeteorSettings.WARNING,"3.2"),changed(MeteorSettings.RADIUS,"0"),changed(MeteorSettings.VERTICAL,"-1"),changed(MeteorSettings.RADIUS,"NaN")))assertThrows(IllegalArgumentException.class,()->MeteorSettings.parse(values));}
|
||||
@Test void persistsBeforePublicationAndPreservesUnrelatedYaml()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: keep\n");try(var store=new MeteorSettings(data,defaults())){var update=store.set(MeteorSettings.RADIUS,"6.00").toCompletableFuture().get();assertEquals("5",store.value(MeteorSettings.RADIUS));assertEquals("6",update.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));store.publish(update);assertEquals("6",store.value(MeteorSettings.RADIUS));}}
|
||||
@Test void failedUnsafeAndClosedWritesRetainEffectiveState()throws Exception{var fail=new java.util.concurrent.atomic.AtomicBoolean(true);try(var store=new MeteorSettings(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,()->store.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());assertEquals("5",store.value(MeteorSettings.RADIUS));}if(!System.getProperty("os.name","").toLowerCase().contains("win")){var unsafe=data.resolve("unsafe");Files.createDirectories(unsafe);var outside=data.resolve("outside");Files.writeString(outside,"safe\n");Files.createSymbolicLink(unsafe.resolve("config.yml"),outside);try(var store=new MeteorSettings(unsafe,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->store.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());assertEquals("safe\n",Files.readString(outside));}}var closed=new MeteorSettings(data.resolve("closed"),defaults());closed.close();assertThrows(java.util.concurrent.ExecutionException.class,()->closed.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());closed.drained().toCompletableFuture().get();}
|
||||
private static Map<String,String> defaults(){var result=new LinkedHashMap<String,String>();for(var key:MeteorSettings.keys())result.put(key,MeteorSettings.defaultValue(key));return result;}
|
||||
private static Map<String,String> changed(String key,String value){var result=new LinkedHashMap<>(defaults());result.put(key,value);return result;}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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.time.Duration;
|
||||
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 OutOfCombatRecoverySettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void parsesReviewedWholeNumberBoundaries() {
|
||||
var defaults = defaults();
|
||||
var parsed = OutOfCombatRecoverySettings.parse(defaults);
|
||||
assertEquals(Duration.ofSeconds(30), parsed.delay());
|
||||
assertEquals(Duration.ofSeconds(10), parsed.interval());
|
||||
assertEquals(java.math.BigInteger.ONE, parsed.amount());
|
||||
|
||||
var boundaries = new LinkedHashMap<>(defaults);
|
||||
boundaries.put(OutOfCombatRecoverySettings.DELAY, "0");
|
||||
boundaries.put(OutOfCombatRecoverySettings.INTERVAL, "31536000");
|
||||
boundaries.put(OutOfCombatRecoverySettings.AMOUNT, "1024");
|
||||
parsed = OutOfCombatRecoverySettings.parse(boundaries);
|
||||
assertEquals(Duration.ZERO, parsed.delay());
|
||||
assertEquals(Duration.ofSeconds(31_536_000), parsed.interval());
|
||||
assertEquals(java.math.BigInteger.valueOf(1024), parsed.amount());
|
||||
|
||||
for (var invalid : java.util.List.of(
|
||||
Map.entry(OutOfCombatRecoverySettings.DELAY, "-1"),
|
||||
Map.entry(OutOfCombatRecoverySettings.DELAY, "31536001"),
|
||||
Map.entry(OutOfCombatRecoverySettings.DELAY, "1.5"),
|
||||
Map.entry(OutOfCombatRecoverySettings.INTERVAL, "0"),
|
||||
Map.entry(OutOfCombatRecoverySettings.INTERVAL, "31536001"),
|
||||
Map.entry(OutOfCombatRecoverySettings.AMOUNT, "-1"),
|
||||
Map.entry(OutOfCombatRecoverySettings.AMOUNT, "1025"),
|
||||
Map.entry(OutOfCombatRecoverySettings.AMOUNT, "1.0"))) {
|
||||
var values = new LinkedHashMap<>(defaults);
|
||||
values.put(invalid.getKey(), invalid.getValue());
|
||||
assertThrows(IllegalArgumentException.class, () -> OutOfCombatRecoverySettings.parse(values), invalid.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void serializesConcurrentWritesPreservesUnrelatedAndPublishesOnlyAcknowledgedSnapshots() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new OutOfCombatRecoverySettings(data, defaults())) {
|
||||
var delay = settings.set(OutOfCombatRecoverySettings.DELAY, "45").toCompletableFuture();
|
||||
var amount = settings.set(OutOfCombatRecoverySettings.AMOUNT, "3").toCompletableFuture();
|
||||
var d = delay.get(5, TimeUnit.SECONDS); var a = amount.get(5, TimeUnit.SECONDS);
|
||||
assertEquals("30", settings.value(OutOfCombatRecoverySettings.DELAY));
|
||||
settings.publish(a); settings.publish(d);
|
||||
assertEquals("45", settings.value(OutOfCombatRecoverySettings.DELAY));
|
||||
assertEquals("3", settings.value(OutOfCombatRecoverySettings.AMOUNT));
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", saved.get("unrelated"));
|
||||
assertEquals("45", String.valueOf(saved.get(OutOfCombatRecoverySettings.DELAY)));
|
||||
assertEquals("3", String.valueOf(saved.get(OutOfCombatRecoverySettings.AMOUNT)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReplacementUnsafeFilesAndCloseRetainEffectiveValues() throws Exception {
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new OutOfCombatRecoverySettings(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(OutOfCombatRecoverySettings.AMOUNT, "2").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("1", settings.value(OutOfCombatRecoverySettings.AMOUNT));
|
||||
}
|
||||
|
||||
var closed = new OutOfCombatRecoverySettings(data.resolve("closed"), defaults());
|
||||
closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
closed.set(OutOfCombatRecoverySettings.DELAY, "1").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String, String> defaults() {
|
||||
var values = new LinkedHashMap<String, String>();
|
||||
for (String key : OutOfCombatRecoverySettings.keys()) values.put(key, OutOfCombatRecoverySettings.defaultValue(key));
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class PassiveStorageTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void learnsLowestSlotPersistsUnknownIdsAndRefundsExactlyOnceWithoutChangingActiveLoadout() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var eligible = eligible(List.of("", "future_unknown", "")); repository.create(eligible);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var learned = storage.unlockPassive(eligible.id(), eligible.revision(), "quicker_regeneration_i",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE).get();
|
||||
assertEquals(CharacterStorage.PassiveMutationStatus.APPLIED, learned.status());
|
||||
var saved = learned.record().orElseThrow();
|
||||
assertEquals(List.of("quicker_regeneration_i", "future_unknown", ""), saved.passives());
|
||||
assertEquals(BigInteger.valueOf(39), saved.unspentPoints());
|
||||
assertEquals(eligible.loadout(), saved.loadout());
|
||||
assertEquals(CharacterStorage.PassiveMutationStatus.STALE, storage.unlockPassive(eligible.id(), eligible.revision(),
|
||||
"quicker_regeneration_i", Map.of(Stat.CON, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE).get().status());
|
||||
|
||||
var refunded = storage.unlearnPassive(saved.id(), saved.revision(), "quicker_regeneration_i", Set.of()).get();
|
||||
assertEquals(CharacterStorage.PassiveMutationStatus.APPLIED, refunded.status());
|
||||
assertEquals(List.of("", "future_unknown", ""), refunded.record().orElseThrow().passives());
|
||||
assertEquals(BigInteger.valueOf(40), refunded.record().orElseThrow().unspentPoints());
|
||||
assertEquals(CharacterStorage.PassiveMutationStatus.NOT_LEARNED,
|
||||
storage.unlearnPassive(saved.id(), refunded.record().orElseThrow().revision(),
|
||||
"quicker_regeneration_i", Set.of()).get().status());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void requirementsPointsCapacityDependenciesAndDeadStateRefuseWithoutMutation() throws Exception {
|
||||
for (int route = 0; route < 5; route++) {
|
||||
var directory = data.resolve("route-" + route); var repository = new YamlCharacterRepository(directory);
|
||||
var base = eligible(route == 2 ? List.of("unknown_a", "unknown_b", "unknown_c") : List.of("", "", ""));
|
||||
var stats = new EnumMap<>(base.stats());
|
||||
var points = base.unspentPoints(); var dead = false; var death = (java.util.UUID) null; var died = (Instant) null;
|
||||
Set<String> prerequisite = Set.of();
|
||||
if (route == 0) stats.put(Stat.CON, BigInteger.valueOf(11));
|
||||
if (route == 1) points = BigInteger.ZERO;
|
||||
if (route == 3) prerequisite = Set.of("required_passive");
|
||||
if (route == 4) { dead = true; death = java.util.UUID.randomUUID(); died = Instant.parse("2026-09-21T00:00:00Z"); }
|
||||
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(), points,
|
||||
dead ? BigDecimal.ZERO : base.health(), base.learned(), base.loadout(), base.passives(), base.cooldowns(),
|
||||
base.restrictedKills(), dead, death, died);
|
||||
repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var result = storage.unlockPassive(record.id(), record.revision(), "quicker_regeneration_i",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), prerequisite, BigInteger.ONE).get();
|
||||
var expected = switch (route) {
|
||||
case 0, 3 -> CharacterStorage.PassiveMutationStatus.REQUIREMENTS;
|
||||
case 1 -> CharacterStorage.PassiveMutationStatus.NO_POINTS;
|
||||
case 2 -> CharacterStorage.PassiveMutationStatus.PASSIVE_SLOTS_FULL;
|
||||
default -> CharacterStorage.PassiveMutationStatus.UNAVAILABLE;
|
||||
};
|
||||
assertEquals(expected, result.status());
|
||||
assertEquals(record, repository.load(record.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedSaveLeavesPointsSlotsAndRevisionUnchanged() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data); var record = eligible(List.of("", "", "")); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC());
|
||||
var channel = java.nio.channels.FileChannel.open(data.resolve("characters").resolve(".records.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> storage.unlockPassive(record.id(), record.revision(),
|
||||
"quicker_regeneration_i", Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)),
|
||||
Set.of(), BigInteger.ONE).get());
|
||||
assertEquals(record, repository.load(record.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void yamlLegacyTailDeathAndReviveRetainOrderedPassives() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data); var record = eligible(List.of("quicker_regeneration_i", "future_unknown", ""));
|
||||
repository.create(record);
|
||||
assertEquals(record, repository.load(record.id()).orElseThrow());
|
||||
String yaml = java.nio.file.Files.readString(data.resolve("characters").resolve(record.id() + ".yml"));
|
||||
assertTrue(yaml.contains("learned-passives:")); assertTrue(yaml.contains("future_unknown"));
|
||||
assertEquals(List.of("", "", ""), CharacterYaml.decode(yaml.replaceFirst("(?m)^learned-passives:.*\\R", "")).passives());
|
||||
var clock = Clock.fixed(Instant.parse("2026-09-21T00:00:00Z"), ZoneOffset.UTC);
|
||||
try (var storage = new CharacterStorage(repository, clock)) {
|
||||
var dead = storage.recordDeath(record.id(), record.revision()).get().orElseThrow();
|
||||
assertEquals(record.passives(), dead.passives());
|
||||
var revived = storage.restoreDeathRecordAdministratively(dead.id(), dead.deathId(), new BigDecimal("1024")).get().orElseThrow();
|
||||
assertEquals(record.passives(), revived.passives());
|
||||
}
|
||||
}
|
||||
|
||||
private static CharacterRecord eligible(List<String> passives) {
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
|
||||
var stats = new EnumMap<>(base.stats()); stats.put(Stat.CON, BigInteger.valueOf(12)); stats.put(Stat.WIS, BigInteger.valueOf(12));
|
||||
return new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
|
||||
BigInteger.valueOf(40), BigDecimal.valueOf(10), base.learned(), base.loadout(), passives, base.cooldowns(),
|
||||
base.restrictedKills(), false, null, null);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user