Compare commits

...
3 Commits
Author SHA1 Message Date
dmg fa219d9313 feat(rewards): add Witch hard-mob XP
Release / release (push) Successful in 8m30s
2026-09-21 18:21:10 -04:00
dmg d98a2a80a3 feat(combat): add base damage to basic attack
Release / release (push) Successful in 9m29s
2026-09-21 18:06:37 -04:00
dmg 7a85eea769 feat(book): add passive return navigation
Release / release (push) Successful in 9m24s
2026-09-21 17:19:47 -04:00
15 changed files with 536 additions and 47 deletions

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])) {
+1
View File
@@ -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)),