Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa219d9313 | ||
|
|
d98a2a80a3 | ||
|
|
7a85eea769 |
No files matched your search
@@ -119,6 +119,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
|
||||
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
|
||||
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
|
||||
private games.dmg.fantasystick.persistence.HardMobXpSettings hardMobXpSettings;
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
|
||||
@@ -186,7 +187,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY),
|
||||
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY), sheetPackDefaults);
|
||||
var rules = progressionRules();
|
||||
var rewardRules = combatRewardRules();
|
||||
var hardMobXpConfiguration = getConfig().getString(
|
||||
games.dmg.fantasystick.persistence.HardMobXpSettings.KEY,
|
||||
games.dmg.fantasystick.persistence.HardMobXpSettings.DEFAULT);
|
||||
var hardMobXp = games.dmg.fantasystick.persistence.HardMobXpSettings.parse(hardMobXpConfiguration);
|
||||
var rewardRules = combatRewardRules(hardMobXp);
|
||||
var fireballConfiguration = fireballConfiguration();
|
||||
var fireballInitial = games.dmg.fantasystick.persistence.FireballSettings.parse(fireballConfiguration);
|
||||
var greatSwordSettings = greatSwordSettings();
|
||||
@@ -230,6 +235,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getDataFolder().toPath(), partyEconomyConfiguration);
|
||||
gearSettings=new games.dmg.fantasystick.persistence.GearSettings(getDataFolder().toPath(),gearConfiguration);
|
||||
coinSettings = new games.dmg.fantasystick.persistence.CoinSettings(getDataFolder().toPath(), coinConfiguration);
|
||||
hardMobXpSettings = new games.dmg.fantasystick.persistence.HardMobXpSettings(
|
||||
getDataFolder().toPath(), hardMobXp.toString());
|
||||
var factory = new CharacterFactory(coinSettings::rules);
|
||||
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC(),
|
||||
coinSettings::rules, java.util.random.RandomGenerator.getDefault());
|
||||
@@ -549,6 +556,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
admin.attachArmorers(armorers,armorerController);
|
||||
admin.attachArmorMarket(armorMarket);
|
||||
admin.attachCoinSettings(coinSettings);
|
||||
admin.attachHardMobXpSettings(hardMobXpSettings, combatExperience);
|
||||
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
|
||||
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
|
||||
admin.attachQuickerRegeneration(quickerRegenerationSettings, quickerRegeneration, passiveRegistry);
|
||||
@@ -716,6 +724,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingPartyEconomySettings = partyEconomySettings; partyEconomySettings = null;
|
||||
var closingGearSettings=gearSettings;gearSettings=null;
|
||||
var closingCoinSettings = coinSettings; coinSettings = null;
|
||||
var closingHardMobXpSettings = hardMobXpSettings; hardMobXpSettings = null;
|
||||
var closingSheetResourcePackSettings = sheetResourcePackSettings; sheetResourcePackSettings = null;
|
||||
var closingSheetResourcePack = sheetResourcePack; sheetResourcePack = null;
|
||||
var closingTemporaryHealth = temporaryHealth; temporaryHealth = null;
|
||||
@@ -780,6 +789,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingPartyEconomySettings != null) cleanup("party economy configuration", closingPartyEconomySettings::close);
|
||||
if(closingGearSettings!=null)cleanup("gear configuration",closingGearSettings::close);
|
||||
if (closingCoinSettings != null) cleanup("coin configuration", closingCoinSettings::close);
|
||||
if (closingHardMobXpSettings != null) cleanup("hard-mob XP configuration", closingHardMobXpSettings::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);
|
||||
@@ -802,6 +812,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingGearSettings==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingGearSettings.drained().toCompletableFuture(),
|
||||
closingPartyEconomySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPartyEconomySettings.drained().toCompletableFuture(),
|
||||
closingHardMobXpSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingHardMobXpSettings.drained().toCompletableFuture(),
|
||||
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSettings.drained().toCompletableFuture(),
|
||||
closingFireballSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -969,13 +981,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules() {
|
||||
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules(java.math.BigInteger hard) {
|
||||
try {
|
||||
var passive = new java.math.BigInteger(getConfig().getString("passive-xp", "1"));
|
||||
var ordinary = new java.math.BigInteger(getConfig().getString("ordinary-xp", "10"));
|
||||
var boss = new java.math.BigInteger(getConfig().getString("boss-xp", "100"));
|
||||
var spacing = new java.math.BigDecimal(getConfig().getString("restricted-kill-spacing-blocks", "3"));
|
||||
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, boss, spacing);
|
||||
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, hard, boss, spacing);
|
||||
} catch (NumberFormatException invalid) {
|
||||
throw new IllegalArgumentException("Combat reward settings must be decimal numbers", invalid);
|
||||
}
|
||||
|
||||
@@ -162,8 +162,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
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 placeholderPassiveDetails = passiveDefinitions.stream()
|
||||
.map(definition -> passiveDetailPages(record, definition, passiveNames, passiveView, 1)).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(
|
||||
@@ -174,10 +174,15 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
int passiveCount = passiveSlotPages(record, passiveDefinitions, passivePlaceholderTargets).size();
|
||||
var placeholderIndex = indexPages(definitions, placeholderTargets);
|
||||
int indexCount = placeholderIndex.pages().size();
|
||||
int passiveIndexCount = passiveIndexPages(passiveDefinitions, passivePlaceholderTargets).size();
|
||||
var placeholderPassiveIndex = passiveIndexPages(passiveDefinitions, passivePlaceholderTargets);
|
||||
int passiveIndexCount = placeholderPassiveIndex.pages().size();
|
||||
int indexStart = pages.size() + activeCount + passiveCount;
|
||||
var indexTargets = new java.util.HashMap<String, Integer>();
|
||||
placeholderIndex.relativePageByAbility().forEach((id, page) -> indexTargets.put(id, indexStart + page + 1));
|
||||
int passiveIndexStart = indexStart + indexCount;
|
||||
var passiveIndexTargets = new java.util.HashMap<String, Integer>();
|
||||
placeholderPassiveIndex.relativePageByPassive().forEach((id, page) ->
|
||||
passiveIndexTargets.put(id, passiveIndexStart + page + 1));
|
||||
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++) {
|
||||
@@ -187,15 +192,18 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
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();
|
||||
target += placeholderPassiveDetails.get(index).size();
|
||||
}
|
||||
var index = indexPages(definitions, targets);
|
||||
var details = definitions.stream()
|
||||
.map(definition -> abilityPages(record, definition, abilityView, indexTargets.get(definition.id()))).toList();
|
||||
var passiveIndex = passiveIndexPages(passiveDefinitions, passiveTargets);
|
||||
var passiveDetails = passiveDefinitions.stream().map(definition -> passiveDetailPages(record, definition,
|
||||
passiveNames, passiveView, passiveIndexTargets.get(definition.id()))).toList();
|
||||
pages.addAll(activePages(record, definitions, targets));
|
||||
pages.addAll(passiveSlotPages(record, passiveDefinitions, passiveTargets));
|
||||
pages.addAll(index.pages());
|
||||
pages.addAll(passiveIndexPages(passiveDefinitions, passiveTargets));
|
||||
pages.addAll(passiveIndex.pages());
|
||||
details.forEach(pages::addAll);
|
||||
passiveDetails.forEach(pages::addAll);
|
||||
}
|
||||
@@ -293,12 +301,17 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
var relativePages = new java.util.HashMap<String, Integer>();
|
||||
for (var category : games.dmg.fantasystick.ability.AbilityDefinition.Category.values()) {
|
||||
var grouped = definitions.stream().filter(definition -> definition.category() == category).toList();
|
||||
for (int start = 0; start < grouped.size(); start += 11) {
|
||||
int cursor = 0;
|
||||
while (cursor < grouped.size()) {
|
||||
var builder = new PageBuilder(); builder.text("Ability index"); builder.text(category.name());
|
||||
int relativePage = pages.size();
|
||||
for (var definition : grouped.subList(start, Math.min(start + 11, grouped.size()))) {
|
||||
int usedLines = 0;
|
||||
while (cursor < grouped.size()) {
|
||||
var definition = grouped.get(cursor);
|
||||
int linkLines = BookPagination.lines(definition.name()).size();
|
||||
if (usedLines > 0 && usedLines + linkLines > 11) break;
|
||||
relativePages.put(definition.id(), pages.size());
|
||||
builder.link("", definition.name(), targets.get(definition.id()));
|
||||
relativePages.put(definition.id(), relativePage);
|
||||
usedLines += linkLines; cursor++;
|
||||
}
|
||||
pages.addAll(builder.pages());
|
||||
}
|
||||
@@ -306,21 +319,33 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
return new AbilityIndex(pages, relativePages);
|
||||
}
|
||||
|
||||
private static java.util.List<Component> passiveIndexPages(
|
||||
private record PassiveIndex(java.util.List<Component> pages, Map<String, Integer> relativePageByPassive) {
|
||||
private PassiveIndex { pages = java.util.List.copyOf(pages); relativePageByPassive = Map.copyOf(relativePageByPassive); }
|
||||
}
|
||||
|
||||
private static PassiveIndex 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 pages = new java.util.ArrayList<Component>(); var relativePages = new java.util.HashMap<String, Integer>();
|
||||
int cursor = 0;
|
||||
while (cursor < definitions.size()) {
|
||||
var builder = new PageBuilder(); builder.text("Passive index"); builder.blank();
|
||||
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
|
||||
int usedLines = 0;
|
||||
while (cursor < definitions.size()) {
|
||||
var definition = definitions.get(cursor);
|
||||
int linkLines = BookPagination.lines(definition.name()).size();
|
||||
if (usedLines > 0 && usedLines + linkLines > 11) break;
|
||||
relativePages.put(definition.id(), pages.size());
|
||||
builder.link("", definition.name(), targets.get(definition.id()));
|
||||
usedLines += linkLines; cursor++;
|
||||
}
|
||||
pages.addAll(builder.pages());
|
||||
}
|
||||
return java.util.List.copyOf(pages);
|
||||
return new PassiveIndex(pages, relativePages);
|
||||
}
|
||||
|
||||
private static java.util.List<Component> passiveDetailPages(CharacterRecord record,
|
||||
games.dmg.fantasystick.passive.PassiveDefinition definition, Map<String, String> names, PassiveView view) {
|
||||
games.dmg.fantasystick.passive.PassiveDefinition definition, Map<String, String> names, PassiveView view,
|
||||
int indexPage) {
|
||||
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()) {
|
||||
@@ -341,6 +366,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
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);
|
||||
builder.component(Component.text("[Back to index]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(indexPage)));
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
static BigInteger damage(BigInteger strength) {
|
||||
Objects.requireNonNull(strength);
|
||||
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
|
||||
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).max(BigInteger.ONE);
|
||||
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.ONE).max(BigInteger.ONE);
|
||||
}
|
||||
|
||||
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
|
||||
|
||||
@@ -57,7 +57,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final ProgressionRules progression;
|
||||
private final CombatRewardRules rewards;
|
||||
private volatile CombatRewardRules rewards;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
@@ -81,12 +81,13 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
final BigInteger experience;
|
||||
final BigInteger coins;
|
||||
final CombatRewardRules.KillPosition restricted;
|
||||
final CombatRewardRules rewardRules;
|
||||
BigInteger expectedRevision;
|
||||
boolean running;
|
||||
PendingAward(UUID id, UUID character, BigInteger experience, BigInteger coins,
|
||||
CombatRewardRules.KillPosition restricted) {
|
||||
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules) {
|
||||
this.id = id; this.character = character; this.experience = experience;
|
||||
this.coins = coins; this.restricted = restricted;
|
||||
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,14 +194,16 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
BigInteger coinPool = defeatedCharacter == null
|
||||
? coinRules.get().mob(coinTarget(target), random) : defeatedCoins;
|
||||
if (units.isEmpty()) return; // The defeat transition has already persisted any PvP balance loss.
|
||||
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(rewards.pool(kind, level), units, random);
|
||||
var capturedRewards = rewards;
|
||||
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(capturedRewards.pool(kind, level), units, random);
|
||||
var coins = games.dmg.fantasystick.progression.RewardAllocator.allocate(coinPool, units, random);
|
||||
CombatRewardRules.KillPosition restricted = kind == CombatRewardRules.Target.PASSIVE
|
||||
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
|
||||
UUID targetReward = defeatedCharacter == null ? target.getUniqueId() : defeatedCharacter;
|
||||
for (UUID character : xp.keySet()) {
|
||||
UUID receipt = UUID.nameUUIDFromBytes((targetReward + ":" + character + ":" + now).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
var operation = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted);
|
||||
var operation = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,
|
||||
capturedRewards);
|
||||
pending.put(operation.id, operation);
|
||||
attempt(operation);
|
||||
}
|
||||
@@ -259,7 +262,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
return;
|
||||
}
|
||||
characters.awardCombatReward(operation.character, operation.expectedRevision, operation.id,
|
||||
operation.experience, operation.coins, progression, rewards, operation.restricted).whenComplete((award, failure) -> {
|
||||
operation.experience, operation.coins, progression, operation.rewardRules, operation.restricted).whenComplete((award, failure) -> {
|
||||
synchronized (operation) { operation.running = false; }
|
||||
if (failure != null) { fail(operation, failure); return; }
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
|
||||
@@ -319,9 +322,17 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
public void configure(CombatRewardRules updated) {
|
||||
if (closed) throw new IllegalStateException("Combat rewards are closed");
|
||||
rewards = Objects.requireNonNull(updated);
|
||||
}
|
||||
|
||||
public CombatRewardRules rules() { return rewards; }
|
||||
|
||||
public static CombatRewardRules.Target classify(LivingEntity entity) {
|
||||
EntityType type = entity.getType();
|
||||
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON) return CombatRewardRules.Target.BOSS;
|
||||
if (type == EntityType.WITCH) return CombatRewardRules.Target.HARD;
|
||||
return PASSIVE.contains(type) ? CombatRewardRules.Target.PASSIVE : CombatRewardRules.Target.ORDINARY;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publication Witch hard-mob XP pool. */
|
||||
public final class HardMobXpSettings implements AutoCloseable {
|
||||
public static final String KEY = "hard-mob-xp";
|
||||
public static final String DEFAULT = "20";
|
||||
private static final BigInteger MAXIMUM = BigInteger.valueOf(Integer.MAX_VALUE);
|
||||
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, BigInteger pool, long sequence) { }
|
||||
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-hard-mob-xp-configuration"));
|
||||
private volatile String effective;
|
||||
private String persisted;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public HardMobXpSettings(Path directory, String initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
HardMobXpSettings(Path directory, String initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
effective = canonical(initial); persisted = effective;
|
||||
}
|
||||
|
||||
public static BigInteger parse(String raw) {
|
||||
try {
|
||||
var value = new BigInteger(raw);
|
||||
if (value.signum() < 0 || value.compareTo(MAXIMUM) > 0) throw new IllegalArgumentException();
|
||||
return value;
|
||||
} catch (RuntimeException invalid) {
|
||||
throw new IllegalArgumentException("Hard-mob XP must be a whole number from 0 through 2147483647", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String raw) { return parse(raw).toString(); }
|
||||
public String value() { return effective; }
|
||||
public BigInteger pool() { 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 CompletionException(failure instanceof IllegalArgumentException ? failure
|
||||
: new IOException("Hard-mob XP 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) {
|
||||
Objects.requireNonNull(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); }
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Validated combat reward arithmetic and location policy; native classification lives at the adapter. */
|
||||
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
|
||||
BigDecimal restrictedSpacing) {
|
||||
public enum Target { PASSIVE, ORDINARY, BOSS, FANTASY_CHARACTER }
|
||||
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger hardPool,
|
||||
BigInteger bossPool, BigDecimal restrictedSpacing) {
|
||||
public enum Target { PASSIVE, ORDINARY, HARD, BOSS, FANTASY_CHARACTER }
|
||||
public static final Duration CONTRIBUTION_LIFETIME = Duration.ofMinutes(5);
|
||||
|
||||
public record KillPosition(UUID world, BigDecimal x, BigDecimal y, BigDecimal z) {
|
||||
@@ -26,16 +26,26 @@ public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool,
|
||||
}
|
||||
|
||||
public CombatRewardRules {
|
||||
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool);
|
||||
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool); Objects.requireNonNull(hardPool);
|
||||
Objects.requireNonNull(bossPool); Objects.requireNonNull(restrictedSpacing);
|
||||
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || bossPool.signum() < 0
|
||||
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || hardPool.signum() < 0 || bossPool.signum() < 0
|
||||
|| restrictedSpacing.signum() < 0 || !Double.isFinite(restrictedSpacing.doubleValue())) {
|
||||
throw new IllegalArgumentException("Combat reward values must be finite and nonnegative");
|
||||
}
|
||||
}
|
||||
|
||||
public CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
|
||||
BigDecimal restrictedSpacing) {
|
||||
this(passivePool, ordinaryPool, BigInteger.valueOf(20), bossPool, restrictedSpacing);
|
||||
}
|
||||
|
||||
public static CombatRewardRules defaults() {
|
||||
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), BigDecimal.valueOf(3));
|
||||
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(20),
|
||||
BigInteger.valueOf(100), BigDecimal.valueOf(3));
|
||||
}
|
||||
|
||||
public CombatRewardRules withHardPool(BigInteger pool) {
|
||||
return new CombatRewardRules(passivePool, ordinaryPool, pool, bossPool, restrictedSpacing);
|
||||
}
|
||||
|
||||
public BigInteger pool(Target target, int defeatedLevel) {
|
||||
@@ -47,6 +57,7 @@ public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool,
|
||||
return switch (target) {
|
||||
case PASSIVE -> passivePool;
|
||||
case ORDINARY -> ordinaryPool;
|
||||
case HARD -> hardPool;
|
||||
case BOSS -> bossPool;
|
||||
case FANTASY_CHARACTER -> throw new IllegalStateException("Handled fantasy character");
|
||||
};
|
||||
|
||||
@@ -69,6 +69,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
|
||||
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
|
||||
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
|
||||
private games.dmg.fantasystick.persistence.HardMobXpSettings hardMobXpSettings;
|
||||
private games.dmg.fantasystick.party.CombatExperience combatExperience;
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
|
||||
@@ -316,6 +318,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
public void attachPartyEconomySettings(games.dmg.fantasystick.persistence.PartyEconomySettings settings){if(closed||partyEconomySettings!=null)throw new IllegalStateException("Party economy settings already attached or command closed");partyEconomySettings=Objects.requireNonNull(settings);}
|
||||
public void attachGearSettings(games.dmg.fantasystick.persistence.GearSettings settings){if(closed||gearSettings!=null)throw new IllegalStateException("Gear settings already attached or command closed");gearSettings=Objects.requireNonNull(settings);}
|
||||
public void attachCoinSettings(games.dmg.fantasystick.persistence.CoinSettings settings){if(closed||coinSettings!=null)throw new IllegalStateException("Coin settings already attached or command closed");coinSettings=Objects.requireNonNull(settings);}
|
||||
public void attachHardMobXpSettings(games.dmg.fantasystick.persistence.HardMobXpSettings settings,
|
||||
games.dmg.fantasystick.party.CombatExperience experience) {
|
||||
if (closed || hardMobXpSettings != null) throw new IllegalStateException("Hard-mob XP settings already attached or command closed");
|
||||
hardMobXpSettings = Objects.requireNonNull(settings); combatExperience = Objects.requireNonNull(experience);
|
||||
}
|
||||
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) {
|
||||
@@ -691,6 +698,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (partyEconomySettings != null && games.dmg.fantasystick.persistence.PartyEconomySettings.keys().contains(args[1])) { partyEconomyConfiguration(sender,args); return; }
|
||||
if(gearSettings!=null&&games.dmg.fantasystick.persistence.GearSettings.keys().contains(args[1])){gearConfiguration(sender,args);return;}
|
||||
if (coinSettings != null && games.dmg.fantasystick.persistence.CoinSettings.keys().contains(args[1])) { coinConfiguration(sender,args); return; }
|
||||
if (hardMobXpSettings != null && args[1].equals(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY)) {
|
||||
hardMobXpConfiguration(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
|
||||
@@ -1319,6 +1329,33 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+coinSettings.value(key)+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&coinSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void hardMobXpConfiguration(CommandSender sender, String[] args) {
|
||||
String key = games.dmg.fantasystick.persistence.HardMobXpSettings.KEY;
|
||||
if (args.length == 2) { sender.sendMessage(key + "=" + hardMobXpSettings.value()
|
||||
+ " (applies to future uncaptured Witch defeats only)"); return; }
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HardMobXpSettings.Update> operation;
|
||||
try { operation = hardMobXpSettings.set(args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Hard-mob XP is invalid; enter a whole number from 0 through 2147483647."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
boolean applied = failure == null && hardMobXpSettings.publish(saved);
|
||||
if (applied) combatExperience.configure(combatExperience.rules().withHardPool(saved.pool()));
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) sender.sendMessage("Hard-mob XP save failed; the effective pool is unchanged.");
|
||||
else if (!applied) sender.sendMessage("Saved request was superseded; effective " + key + "="
|
||||
+ hardMobXpSettings.value() + ".");
|
||||
else sender.sendMessage("Saved " + key + "=" + saved.value()
|
||||
+ " for future uncaptured Witch defeats; existing progress and captured rewards are unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void partyConfiguration(CommandSender sender,String[] args){if(partySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+" (existing parties are retained)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartySettings.Update> operation;try{operation=partySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party maximum is invalid; enter a positive whole number.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-party",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null)applied=partySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party configuration save failed; the effective limit and rosters are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+saved.value()+"; existing members are retained.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fantasystick config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
@@ -1387,6 +1424,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
keys.addAll(games.dmg.fantasystick.persistence.PartyEconomySettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.GearSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.CoinSettings.keys());
|
||||
keys.add(games.dmg.fantasystick.persistence.HardMobXpSettings.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);
|
||||
@@ -1420,6 +1458,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if(partyEconomySettings!=null&&games.dmg.fantasystick.persistence.PartyEconomySettings.keys().contains(args[1])){String current=partyEconomySettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.PartyEconomySettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
|
||||
if(gearSettings!=null&&games.dmg.fantasystick.persistence.GearSettings.keys().contains(args[1])){String current=gearSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.GearSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
|
||||
if(coinSettings!=null&&games.dmg.fantasystick.persistence.CoinSettings.keys().contains(args[1])){String current=coinSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.CoinSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
|
||||
if (hardMobXpSettings != null && args[1].equals(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY)) {
|
||||
String current = hardMobXpSettings.value(); String defaults = games.dmg.fantasystick.persistence.HardMobXpSettings.DEFAULT;
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), 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])) {
|
||||
|
||||
@@ -56,6 +56,7 @@ boss-coin-drop-max: 50
|
||||
# Character-bound combat rewards. Restricted hostile/neutral/boss history uses mob death positions.
|
||||
passive-xp: 1
|
||||
ordinary-xp: 10
|
||||
hard-mob-xp: 20
|
||||
boss-xp: 100
|
||||
restricted-kill-spacing-blocks: 3
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class BasicAttackNativeTest {
|
||||
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
var zero = new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
doAnswer(call -> {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getCausingEntity()).thenReturn(a.player);
|
||||
@@ -35,6 +36,7 @@ class BasicAttackNativeTest {
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
|
||||
event.setCancelled(cancelled.get());
|
||||
if (zero.get()) event.setDamage(0);
|
||||
basic.acceptedDamage(event);
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), same(a.player));
|
||||
@@ -43,12 +45,13 @@ class BasicAttackNativeTest {
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1,
|
||||
() -> sessions.current(active));
|
||||
basic.accept(input); // Cancelled: no cadence spent.
|
||||
cancelled.set(false);
|
||||
basic.accept(input); // Accepted at t=0.
|
||||
cancelled.set(false); zero.set(true);
|
||||
basic.accept(input); // Zero final damage: no cadence spent.
|
||||
zero.set(false); basic.accept(input); // Accepted at t=0.
|
||||
clock.advance(java.time.Duration.ofMillis(999)); basic.accept(input);
|
||||
verify(target, times(2)).damage(1.0, a.player);
|
||||
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
|
||||
verify(target, times(3)).damage(1.0, a.player);
|
||||
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
|
||||
verify(target, times(4)).damage(1.0, a.player);
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
|
||||
verifyNoMoreInteractions(target);
|
||||
@@ -88,6 +91,32 @@ class BasicAttackNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedBasicStickUsesBasePlusStrengthModifierAcrossApprovedBoundaries() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
int[] strengths = {0, 9, 10, 11, 12, 13, 14, 15};
|
||||
double[] expected = {1, 1, 1, 1, 2, 2, 3, 3};
|
||||
for (int index = 0; index < strengths.length; index++) {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withStrength(data, actor, java.math.BigInteger.valueOf(strengths[index]));
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
actor.core.inventory.getInventory().setSelectedSlot(1);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true);
|
||||
f.dispatcher.accept(attack);
|
||||
assertTrue(attack.isCancelled()); verify(target).damage(expected[index], actor.player);
|
||||
actor.core.inventory.setItemInOffHand(null); f.changed(actor); f.awaitCharacterAndReceipt(actor);
|
||||
}
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withStrength(data, actor, java.math.BigInteger.TEN.pow(1000));
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
actor.core.inventory.getInventory().setSelectedSlot(1);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true));
|
||||
verify(target).damage(Double.MAX_VALUE, actor.player);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedBasicStickInputDealsOneLowStrengthHitWithoutVanillaContinuation() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -106,6 +135,19 @@ class BasicAttackNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void withStrength(Path data, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
java.math.BigInteger strength) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.STR, strength);
|
||||
repository.replace(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.passives(), old.cooldowns(), old.restrictedKills(),
|
||||
false, null, null, old.coins(), old.coinsInitialized(), old.rewardReceipts(), old.pvpCoinEscrow(), old.gear()),
|
||||
old.revision());
|
||||
}
|
||||
|
||||
private static final class MutableClock extends Clock {
|
||||
private Instant now;
|
||||
MutableClock(Instant now) { this.now = now; }
|
||||
|
||||
@@ -32,6 +32,48 @@ class CombatExperienceNativeTest {
|
||||
games.dmg.fantasystick.party.CombatExperience.coinTarget(wither));
|
||||
assertEquals(games.dmg.fantasystick.progression.CombatRewardRules.Target.ORDINARY,
|
||||
games.dmg.fantasystick.party.CombatExperience.classify(wither));
|
||||
var witch = type(EntityType.WITCH);
|
||||
assertEquals(games.dmg.fantasystick.progression.CombatRewardRules.Target.HARD,
|
||||
games.dmg.fantasystick.party.CombatExperience.classify(witch));
|
||||
assertEquals(games.dmg.fantasystick.progression.CoinRules.Target.HOSTILE,
|
||||
games.dmg.fantasystick.party.CombatExperience.coinTarget(witch));
|
||||
}
|
||||
|
||||
@Test void packagedWitchPoolFlowsThroughExactSoloAndPartyAllocationWithOrdinaryCoins() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var solo = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var c = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
for (var actor : java.util.List.of(solo, a, b, c)) { f.players.put(actor.core.id, actor.player); f.activate(actor); }
|
||||
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()));
|
||||
assertTrue(parties.accept(c.core.id, parties.invite(a.core.id, party.id(), c.core.id).orElseThrow().id()));
|
||||
var beforeCoins = record(f, solo).coins().add(record(f, a).coins())
|
||||
.add(record(f, b).coins()).add(record(f, c).coins());
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var witch = mob(EntityType.WITCH, new Location(world, 10, 64, 0));
|
||||
hit(f, solo.player, witch, false);
|
||||
var projectile = PartyCombatNativeTest.projectile(f, a.player); hit(f, projectile, witch, false);
|
||||
die(f, witch); settle(f); settle(f); settle(f);
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, solo).experience());
|
||||
var partyXp = java.util.List.of(record(f, a).experience(), record(f, b).experience(), record(f, c).experience());
|
||||
assertEquals(java.math.BigInteger.TEN,
|
||||
partyXp.stream().reduce(java.math.BigInteger.ZERO, java.math.BigInteger::add));
|
||||
assertEquals(java.util.List.of(java.math.BigInteger.valueOf(3), java.math.BigInteger.valueOf(3),
|
||||
java.math.BigInteger.valueOf(4)), partyXp.stream().sorted().toList());
|
||||
var afterCoins = record(f, solo).coins().add(record(f, a).coins())
|
||||
.add(record(f, b).coins()).add(record(f, c).coins());
|
||||
var delta = afterCoins.subtract(beforeCoins);
|
||||
assertTrue(delta.compareTo(java.math.BigInteger.ONE) >= 0 && delta.compareTo(java.math.BigInteger.valueOf(5)) <= 0,
|
||||
"Witch uses one ordinary hostile coin pool");
|
||||
assertTrue(solo.messages.stream().anyMatch(message -> message.contains("10") && message.contains("XP")));
|
||||
solo.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, solo);
|
||||
assertTrue(PartyBookNativeTest.text(solo).contains("XP: 10/100"));
|
||||
die(f, witch); settle(f);
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, solo).experience(), "Duplicate death callbacks are consumed once");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedBasicAttackToBossDeathPersistsLevelAndUpdatesBookFeedbackExactlyOnce() throws Exception {
|
||||
@@ -197,13 +239,13 @@ class CombatExperienceNativeTest {
|
||||
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.CombatAward>();
|
||||
failed.completeExceptionally(new java.io.IOException("simulated write failure"));
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), original.name(), java.math.BigInteger.ONE,
|
||||
original.stats(), original.level(), java.math.BigInteger.TEN, original.unspentPoints(), original.health(),
|
||||
original.stats(), original.level(), java.math.BigInteger.valueOf(20), original.unspentPoints(), original.health(),
|
||||
original.learned(), original.loadout(), original.cooldowns(), original.restrictedKills(), false, null, null);
|
||||
var success = java.util.concurrent.CompletableFuture.completedFuture(
|
||||
new games.dmg.fantasystick.persistence.CharacterStorage.CombatAward(
|
||||
games.dmg.fantasystick.persistence.CharacterStorage.CombatAwardStatus.APPLIED, java.util.Optional.of(saved), 0));
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
when(storage.awardCombatReward(eq(original.id()), any(), any(), eq(java.math.BigInteger.TEN), any(),
|
||||
when(storage.awardCombatReward(eq(original.id()), any(), any(), eq(java.math.BigInteger.valueOf(20)), any(),
|
||||
same(progression), any(), any())).thenAnswer(ignored -> writes.getAndIncrement() == 0 ? failed : success);
|
||||
var failures = new ArrayList<games.dmg.fantasystick.party.CombatExperience.Failure>();
|
||||
var service = new games.dmg.fantasystick.party.CombatExperience(storage, progression,
|
||||
@@ -211,13 +253,14 @@ class CombatExperienceNativeTest {
|
||||
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC), Runnable::run,
|
||||
ignored -> attacker, failures::add);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var target = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
|
||||
var target = mob(EntityType.WITCH, new Location(world, 0, 64, 0));
|
||||
var damageSource = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(damageSource.getDirectEntity()).thenReturn(attacker); when(damageSource.getCausingEntity()).thenReturn(attacker);
|
||||
service.damage(new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, damageSource, 1));
|
||||
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
|
||||
verify(storage, times(1)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
|
||||
assertEquals(1, failures.size());
|
||||
service.configure(service.rules().withHardPool(java.math.BigInteger.valueOf(25)));
|
||||
service.retryPending();
|
||||
verify(storage, times(2)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
|
||||
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
|
||||
@@ -261,6 +304,40 @@ class CombatExperienceNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void hardMobXpCommandPersistsBeforePublishingAndAffectsOnlyFutureWitchDefeats() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"hard-mob-xp: 25\nunrelated: retained\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = true;
|
||||
assertEquals(java.util.List.of("hard-mob-xp"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "hard"}));
|
||||
assertEquals(java.util.List.of("20", "25"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp"});
|
||||
assertTrue(sender.messages.getLast().contains("=25"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp", "2147483648"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
var service = (games.dmg.fantasystick.party.CombatExperience)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "combatExperience");
|
||||
assertEquals(java.math.BigInteger.valueOf(25), service.rules().hardPool());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp", "30"});
|
||||
Runnable response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response);
|
||||
assertEquals(java.math.BigInteger.valueOf(25), service.rules().hardPool(), "Durable write precedes publication");
|
||||
response.run(); assertEquals(java.math.BigInteger.valueOf(30), service.rules().hardPool());
|
||||
var yaml = (java.util.Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(
|
||||
java.nio.file.Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", yaml.get("unrelated")); assertEquals("30", String.valueOf(yaml.get("hard-mob-xp")));
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var witch = mob(EntityType.WITCH, new Location(world, 0, 64, 0));
|
||||
hit(f, actor.player, witch, false); die(f, witch); settle(f);
|
||||
assertEquals(java.math.BigInteger.valueOf(30), record(f, actor).experience());
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "hard"}).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void interceptedFantasyDefeatUsesVictimLevelPoolAndCancelledDamageCreatesNoCredit() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
|
||||
@@ -904,6 +904,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
@Test void invalidCombatRewardConfigurationFailsBeforeNativeRegistrationWithoutRewritingConfig() throws Exception {
|
||||
int scenario = 0;
|
||||
for (String row : List.of("passive-xp: -1", "ordinary-xp: 1.5", "boss-xp: nope",
|
||||
"hard-mob-xp: -1", "hard-mob-xp: 2147483648", "hard-mob-xp: 1.5",
|
||||
"restricted-kill-spacing-blocks: -0.1", "restricted-kill-spacing-blocks: 1e1000000")) {
|
||||
var directory = data.resolve("invalid-reward-" + scenario++); java.nio.file.Files.createDirectories(directory);
|
||||
String config = row + "\n"; java.nio.file.Files.writeString(directory.resolve("config.yml"), config);
|
||||
@@ -1288,7 +1289,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(actor.core.marker().endsWith("|PACKED"));
|
||||
}
|
||||
private void captureWorkers(JavaPlugin instance) {
|
||||
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals", "trainingDummies")) {
|
||||
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals", "trainingDummies",
|
||||
"hardMobXpSettings")) {
|
||||
var store = field(instance, name);
|
||||
if (store != null) {
|
||||
var worker = (java.util.concurrent.ExecutorService) field(store, "worker");
|
||||
|
||||
@@ -35,6 +35,28 @@ class QuickerRegenerationNativeTest {
|
||||
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 indexPageByPassive = new java.util.HashMap<String, Integer>();
|
||||
var detailStartByPassive = new java.util.HashMap<String, Integer>();
|
||||
var passiveRegistry = (games.dmg.fantasystick.passive.PassiveRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "passiveRegistry");
|
||||
for (var definition : passiveRegistry.definitions()) {
|
||||
for (int page = passiveIndex; page < pages.size() && pages.get(page).getString().startsWith("Passive index"); page++) {
|
||||
var target = changePageTo(pages.get(page), pages, definition.name());
|
||||
if (target != null) { indexPageByPassive.put(definition.name(), page);
|
||||
detailStartByPassive.put(definition.name(), target.page() - 1); break; }
|
||||
}
|
||||
}
|
||||
assertEquals(passiveRegistry.definitions().size(), detailStartByPassive.size());
|
||||
for (var entry : detailStartByPassive.entrySet()) {
|
||||
int end = party;
|
||||
for (int other : detailStartByPassive.values()) if (other > entry.getValue()) end = Math.min(end, other);
|
||||
var backPages = new java.util.ArrayList<Integer>();
|
||||
for (int candidate = entry.getValue(); candidate < end; candidate++)
|
||||
if (changePageNamed(pages.get(candidate), "[Back to index]") != null) backPages.add(candidate);
|
||||
assertEquals(java.util.List.of(end - 1), backPages, entry.getKey());
|
||||
assertEquals(indexPageByPassive.get(entry.getKey()) + 1,
|
||||
changePageNamed(pages.get(end - 1), "[Back to index]").page(), entry.getKey());
|
||||
}
|
||||
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);
|
||||
@@ -74,6 +96,50 @@ class QuickerRegenerationNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void multiPagePassiveIndexAndWrappedDetailsReturnToTheirExactContainingPage() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var registry = (games.dmg.fantasystick.passive.PassiveRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "passiveRegistry");
|
||||
for (int index = 0; index < 12; index++) {
|
||||
String suffix = String.format(java.util.Locale.ROOT, "%02d", index);
|
||||
registry.register(new games.dmg.fantasystick.passive.PassiveDefinition("synthetic_" + suffix,
|
||||
"Synthetic Passive " + suffix, java.util.Map.of(), java.util.Set.of(), BigInteger.ONE,
|
||||
List.of(index == 0 ? "Wrapped detail ".repeat(80) : "Synthetic passive detail.")));
|
||||
}
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var before = storage(f).find(a.core.record.id()).get().orElseThrow(); PartyBookNativeTest.open(f, a);
|
||||
var pages = nativePages(a); assertTrue(pages.size() <= 100);
|
||||
int passiveIndex = first(pages, "Passive index"), party = first(pages, "Party");
|
||||
var indexPageByPassive = new java.util.HashMap<String, Integer>();
|
||||
var detailStartByPassive = new java.util.HashMap<String, Integer>();
|
||||
int indexPages = 0;
|
||||
for (int page = passiveIndex; page < pages.size() && pages.get(page).getString().startsWith("Passive index"); page++) indexPages++;
|
||||
for (var definition : registry.definitions()) {
|
||||
for (int page = passiveIndex; page < passiveIndex + indexPages; page++) {
|
||||
var target = changePageTo(pages.get(page), pages, definition.name());
|
||||
if (target != null) { indexPageByPassive.put(definition.name(), page);
|
||||
detailStartByPassive.put(definition.name(), target.page() - 1); break; }
|
||||
}
|
||||
}
|
||||
assertTrue(indexPages >= 2); assertEquals(registry.definitions().size(), detailStartByPassive.size());
|
||||
boolean wrapped = false;
|
||||
for (var entry : detailStartByPassive.entrySet()) {
|
||||
int end = party;
|
||||
for (int other : detailStartByPassive.values()) if (other > entry.getValue()) end = Math.min(end, other);
|
||||
wrapped |= end - entry.getValue() > 1;
|
||||
var backPages = new java.util.ArrayList<Integer>();
|
||||
for (int candidate = entry.getValue(); candidate < end; candidate++)
|
||||
if (changePageNamed(pages.get(candidate), "[Back to index]") != null) backPages.add(candidate);
|
||||
assertEquals(java.util.List.of(end - 1), backPages, entry.getKey());
|
||||
assertEquals(indexPageByPassive.get(entry.getKey()) + 1,
|
||||
changePageNamed(pages.get(end - 1), "[Back to index]").page(), entry.getKey());
|
||||
}
|
||||
assertTrue(wrapped, "At least one passive detail must span native pages");
|
||||
assertEquals(before, storage(f).find(before.id()).get().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unknownFullPassiveSlotsRemainVisibleAndRefuseBeforeSpending() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -144,6 +210,15 @@ class QuickerRegenerationNativeTest {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.ChangePage changePageNamed(
|
||||
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 = changePageNamed(child, label); 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);
|
||||
|
||||
@@ -7,13 +7,14 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
class BasicAttackTest {
|
||||
@Test void followsTheApprovedStrengthFormulaWithoutAStatCap() {
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.ZERO));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.TEN));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(11)));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.TWO, BasicAttack.damage(BigInteger.valueOf(14)));
|
||||
for (int strength : new int[]{0, 9, 10, 11})
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(strength)), "STR " + strength);
|
||||
for (int strength : new int[]{12, 13})
|
||||
assertEquals(BigInteger.TWO, BasicAttack.damage(BigInteger.valueOf(strength)), "STR " + strength);
|
||||
for (int strength : new int[]{14, 15})
|
||||
assertEquals(BigInteger.valueOf(3), BasicAttack.damage(BigInteger.valueOf(strength)), "STR " + strength);
|
||||
var huge = BigInteger.TEN.pow(1000);
|
||||
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO), BasicAttack.damage(huge));
|
||||
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.ONE), BasicAttack.damage(huge));
|
||||
assertThrows(IllegalArgumentException.class, () -> BasicAttack.damage(BigInteger.valueOf(-1)));
|
||||
assertThrows(NullPointerException.class, () -> BasicAttack.damage(null));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
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 HardMobXpSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void validatesWholePoolAndPersistsBeforePublicationWithoutLosingOtherKeys() throws Exception {
|
||||
assertEquals(BigInteger.ZERO, HardMobXpSettings.parse("0"));
|
||||
assertEquals(BigInteger.valueOf(20), HardMobXpSettings.parse("20"));
|
||||
assertEquals(BigInteger.valueOf(Integer.MAX_VALUE), HardMobXpSettings.parse("2147483647"));
|
||||
for (String invalid : java.util.List.of("-1", "2147483648", "1.5", "x"))
|
||||
assertThrows(IllegalArgumentException.class, () -> HardMobXpSettings.parse(invalid));
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new HardMobXpSettings(data, "20")) {
|
||||
var update = settings.set("25").toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals("20", settings.value());
|
||||
assertTrue(settings.publish(update)); assertEquals("25", settings.value());
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", saved.get("unrelated"));
|
||||
assertEquals("25", String.valueOf(saved.get(HardMobXpSettings.KEY)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedWriteCloseAndSupersededPublicationRetainAuthoritativeValue() throws Exception {
|
||||
try (var settings = new HardMobXpSettings(data, "20", (from, to) -> {
|
||||
throw new java.io.IOException("simulated");
|
||||
})) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set("25").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("20", settings.value());
|
||||
}
|
||||
try (var settings = new HardMobXpSettings(data.resolve("ordered"), "20")) {
|
||||
var first = settings.set("21").toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var second = settings.set("22").toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertTrue(settings.publish(second)); assertFalse(settings.publish(first)); assertEquals("22", settings.value());
|
||||
}
|
||||
var closed = new HardMobXpSettings(data.resolve("closed"), "20"); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set("25").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,11 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
class CombatRewardRulesTest {
|
||||
@Test void poolsAndRoundedSharesPreserveApprovedArithmetic() {
|
||||
var rules = new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), new BigDecimal("3"));
|
||||
var rules = new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(20),
|
||||
BigInteger.valueOf(100), new BigDecimal("3"));
|
||||
assertEquals(BigInteger.ONE, rules.pool(CombatRewardRules.Target.PASSIVE, 99));
|
||||
assertEquals(BigInteger.TEN, rules.pool(CombatRewardRules.Target.ORDINARY, 99));
|
||||
assertEquals(BigInteger.valueOf(20), rules.pool(CombatRewardRules.Target.HARD, 99));
|
||||
assertEquals(BigInteger.valueOf(100), rules.pool(CombatRewardRules.Target.BOSS, 99));
|
||||
assertEquals(BigInteger.valueOf(70), rules.pool(CombatRewardRules.Target.FANTASY_CHARACTER, 7));
|
||||
assertEquals(BigInteger.valueOf(4), rules.share(BigInteger.TEN, 3));
|
||||
@@ -41,7 +43,10 @@ class CombatRewardRulesTest {
|
||||
|
||||
@Test void rejectsInvalidRewardConfigurationAndArithmeticInputs() {
|
||||
assertAll(
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.valueOf(-1), BigInteger.TEN, BigInteger.TEN, BigDecimal.ONE)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.valueOf(-1), BigInteger.TEN,
|
||||
BigInteger.valueOf(20), BigInteger.TEN, BigDecimal.ONE)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.ONE, BigInteger.TEN,
|
||||
BigInteger.valueOf(-1), BigInteger.TEN, BigDecimal.ONE)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.TEN, new BigDecimal("-0.1"))),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> CombatRewardRules.defaults().share(BigInteger.ONE, 0)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> CombatRewardRules.defaults().pool(CombatRewardRules.Target.FANTASY_CHARACTER, 0)),
|
||||
|
||||
Reference in new issue
Block a user