Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d6b945f82 | ||
|
|
5c5f774dea | ||
|
|
0536d4c717 | ||
|
|
079555900c |
No files matched your search
@@ -98,6 +98,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
|
||||
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
|
||||
private games.dmg.fantasystick.persistence.PartySettings partySettings;
|
||||
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
|
||||
@@ -110,6 +111,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
|
||||
private games.dmg.fantasystick.passive.HealthSteal healthSteal;
|
||||
private games.dmg.fantasystick.passive.SpeedI speedI;
|
||||
private games.dmg.fantasystick.passive.LowLightVision lowLightVision;
|
||||
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
|
||||
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
@@ -140,6 +142,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getConfig().get(games.dmg.fantasystick.persistence.RoleplaySettings.KEY));
|
||||
var partyMaximum = games.dmg.fantasystick.persistence.PartySettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.PartySettings.KEY));
|
||||
var coinConfiguration = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.CoinSettings.keys()) {
|
||||
coinConfiguration.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.CoinSettings.defaultValue(key)));
|
||||
}
|
||||
var recoveryConfiguration = outOfCombatRecoveryConfiguration();
|
||||
var recoveryInitial = games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.parse(recoveryConfiguration);
|
||||
var quickerRegenerationInitial = games.dmg.fantasystick.persistence.QuickerRegenerationSettings.parse(
|
||||
@@ -195,8 +202,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var meteorInitial = games.dmg.fantasystick.persistence.MeteorSettings.parse(meteorConfiguration);
|
||||
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
|
||||
var factory = new CharacterFactory();
|
||||
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
|
||||
coinSettings = new games.dmg.fantasystick.persistence.CoinSettings(getDataFolder().toPath(), coinConfiguration);
|
||||
var factory = new CharacterFactory(coinSettings::rules);
|
||||
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC(),
|
||||
coinSettings::rules, java.util.random.RandomGenerator.getDefault());
|
||||
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
|
||||
task -> getServer().getScheduler().runTask(this, task),
|
||||
player -> new PlayerSheetDrops(player, event -> getServer().getPluginManager().callEvent(event)),
|
||||
@@ -247,8 +256,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
partyEvents = new games.dmg.fantasystick.party.PartyEvents(parties);
|
||||
partyCombat = new games.dmg.fantasystick.party.PartyCombat(parties, sessions);
|
||||
combatExperience = new games.dmg.fantasystick.party.CombatExperience(characters, rules, rewardRules,
|
||||
sessions, Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
|
||||
sessions, parties, coinSettings::rules, java.util.random.RandomGenerator.getDefault(),
|
||||
Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
|
||||
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()));
|
||||
sessions.attachPvpDefeat(combatExperience::consumePvpDefeat);
|
||||
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
|
||||
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
|
||||
frostShardSettings = new games.dmg.fantasystick.persistence.FrostShardSettings(
|
||||
@@ -366,11 +377,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()));
|
||||
speedI = new games.dmg.fantasystick.passive.SpeedI(sessions);
|
||||
lowLightVision = new games.dmg.fantasystick.passive.LowLightVision(sessions);
|
||||
passiveRegistry = new games.dmg.fantasystick.passive.PassiveRegistry();
|
||||
passiveRegistry.register(quickerRegeneration.definition());
|
||||
passiveRegistry.register(quickerRegenerationII.definition());
|
||||
passiveRegistry.register(healthSteal.definition());
|
||||
passiveRegistry.register(speedI.definition());
|
||||
passiveRegistry.register(lowLightVision.definition());
|
||||
passiveEngine = new games.dmg.fantasystick.passive.PassiveEngine(passiveRegistry, characters, sessions, nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Passive change failed for character " + problem.character()
|
||||
+ "; passive " + problem.passive(), problem.cause()));
|
||||
@@ -435,6 +448,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
admin.attachSwitchDelay(switchDelaySettings);
|
||||
admin.attachRoleplaySettings(roleplaySettings);
|
||||
admin.attachPartySettings(partySettings);
|
||||
admin.attachCoinSettings(coinSettings);
|
||||
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
|
||||
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
|
||||
admin.attachQuickerRegeneration(quickerRegenerationSettings, quickerRegeneration, passiveRegistry);
|
||||
@@ -482,6 +496,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(outOfCombatRecovery, this);
|
||||
outOfCombatRecovery.install(this);
|
||||
speedI.install(this);
|
||||
lowLightVision.install(this);
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
getServer().getPluginManager().registerEvents(gameModes, this);
|
||||
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
|
||||
@@ -566,6 +581,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
|
||||
var closingRoleplaySettings = roleplaySettings; roleplaySettings = null;
|
||||
var closingPartySettings = partySettings; partySettings = null;
|
||||
var closingCoinSettings = coinSettings; coinSettings = null;
|
||||
var closingSheetResourcePackSettings = sheetResourcePackSettings; sheetResourcePackSettings = null;
|
||||
var closingSheetResourcePack = sheetResourcePack; sheetResourcePack = null;
|
||||
var closingTemporaryHealth = temporaryHealth; temporaryHealth = null;
|
||||
@@ -587,6 +603,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingHealthStealSettings = healthStealSettings; healthStealSettings = null;
|
||||
var closingHealthSteal = healthSteal; healthSteal = null;
|
||||
var closingSpeedI = speedI; speedI = null;
|
||||
var closingLowLightVision = lowLightVision; lowLightVision = null;
|
||||
var closingPassiveEngine = passiveEngine; passiveEngine = null;
|
||||
quickerRegeneration = null; quickerRegenerationII = null; passiveRegistry = null;
|
||||
// Retire client resources independently of successful ordinary-equipment restoration.
|
||||
@@ -623,6 +640,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
|
||||
if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close);
|
||||
if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close);
|
||||
if (closingCoinSettings != null) cleanup("coin configuration", closingCoinSettings::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);
|
||||
@@ -707,6 +725,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingPassiveEngine != null) cleanup("passive engine", closingPassiveEngine::close);
|
||||
if (closingHealthSteal != null) cleanup("Health Steal", closingHealthSteal::close);
|
||||
if (closingSpeedI != null) cleanup("Speed I", closingSpeedI::close);
|
||||
if (closingLowLightVision != null) cleanup("Low-Light Vision", closingLowLightVision::close);
|
||||
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
|
||||
if (closingParties != null) cleanup("parties", closingParties::close);
|
||||
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
|
||||
|
||||
@@ -219,6 +219,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
builder.text("Level: " + record.level());
|
||||
builder.text("XP: " + xp);
|
||||
builder.text("Unspent points: " + record.unspentPoints());
|
||||
builder.text("Coins: " + record.coins());
|
||||
builder.text("Health: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
|
||||
+ "/" + maximum.stripTrailingZeros().toPlainString());
|
||||
return builder.pages();
|
||||
|
||||
@@ -19,12 +19,26 @@ public final class CharacterFactory {
|
||||
"Hollow", "Lark", "Mist", "Reed", "Stone", "Thorn", "Vale", "Willow"};
|
||||
private final Supplier<UUID> identities;
|
||||
private final RandomGenerator names;
|
||||
private final Supplier<games.dmg.fantasystick.progression.CoinRules> coins;
|
||||
private final RandomGenerator coinRandom;
|
||||
|
||||
public CharacterFactory() { this(UUID::randomUUID, RandomGenerator.getDefault()); }
|
||||
public CharacterFactory() { this(UUID::randomUUID, RandomGenerator.getDefault(),
|
||||
games.dmg.fantasystick.progression.CoinRules::defaults, RandomGenerator.getDefault()); }
|
||||
|
||||
public CharacterFactory(Supplier<games.dmg.fantasystick.progression.CoinRules> coins) {
|
||||
this(UUID::randomUUID, RandomGenerator.getDefault(), coins, RandomGenerator.getDefault());
|
||||
}
|
||||
|
||||
CharacterFactory(Supplier<UUID> identities, RandomGenerator names) {
|
||||
this(identities, names, games.dmg.fantasystick.progression.CoinRules::defaults, names);
|
||||
}
|
||||
|
||||
CharacterFactory(Supplier<UUID> identities, RandomGenerator names,
|
||||
Supplier<games.dmg.fantasystick.progression.CoinRules> coins, RandomGenerator coinRandom) {
|
||||
this.identities = Objects.requireNonNull(identities);
|
||||
this.names = Objects.requireNonNull(names);
|
||||
this.coins = Objects.requireNonNull(coins);
|
||||
this.coinRandom = Objects.requireNonNull(coinRandom);
|
||||
}
|
||||
|
||||
public synchronized CharacterRecord create(ProgressionRules rules) {
|
||||
@@ -34,6 +48,7 @@ public final class CharacterFactory {
|
||||
+ FAMILY_NAMES[names.nextInt(FAMILY_NAMES.length)];
|
||||
return new CharacterRecord(initial.characterId(), name, initial.revision(), initial.stats(),
|
||||
initial.level(), initial.experience(), initial.unspentPoints(), initial.currentHealth(),
|
||||
Set.of(), Collections.nCopies(7, ""), Map.of(), false, null, null);
|
||||
Set.of(), Collections.nCopies(7, ""), java.util.List.of("", "", ""), Map.of(), Map.of(),
|
||||
false, null, null, coins.get().starting(coinRandom), true, Set.of());
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import org.bukkit.persistence.PersistentDataType;
|
||||
* The caller must enforce session custody and suppress vanilla item uses; this class registers no events.
|
||||
*/
|
||||
public final class SessionControls {
|
||||
public static final NamespacedKey COOLDOWN_MODEL = new NamespacedKey("fantasystick", "cooldown_control");
|
||||
private final games.dmg.fantasystick.ability.AbilityRegistry registry;
|
||||
private final Clock clock;
|
||||
private static final NamespacedKey VERSION = key("control_version");
|
||||
@@ -92,6 +93,7 @@ public final class SessionControls {
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(icon.name()));
|
||||
meta.setMaxStackSize(1);
|
||||
if (cooling) meta.setItemModel(COOLDOWN_MODEL);
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
data.set(OWNER, PersistentDataType.STRING, owner.toString());
|
||||
|
||||
@@ -37,7 +37,8 @@ public final class TailBooks {
|
||||
meta.displayName(Component.text(fullTitle));
|
||||
List<Component> pages = new ArrayList<>();
|
||||
var summary = new StringBuilder(fullTitle).append("\nLevel: ").append(character.level())
|
||||
.append("\nXP: ").append(character.experience()).append("\nUnspent points: ").append(character.unspentPoints());
|
||||
.append("\nXP: ").append(character.experience()).append("\nUnspent points: ").append(character.unspentPoints())
|
||||
.append("\nCoins: ").append(character.coins());
|
||||
for (Stat stat : Stat.values()) summary.append('\n').append(stat).append(": ").append(character.stats().get(stat));
|
||||
pages.addAll(BookPagination.pages(summary.toString()));
|
||||
if (character.learned().isEmpty()) pages.addAll(BookPagination.pages("Abilities: None"));
|
||||
|
||||
@@ -39,7 +39,8 @@ import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
/** Accepted native contribution capture and durable character-bound XP delivery. */
|
||||
public final class CombatExperience implements Listener, AutoCloseable {
|
||||
public record Failure(UUID character, Throwable cause) { }
|
||||
private record Defeat(EquipmentSessions.Active active, int level) { }
|
||||
private record Defeat(EquipmentSessions.Active active, int level, BigInteger coins) { }
|
||||
private record Eligible(UUID character, UUID player) { }
|
||||
private static final Set<EntityType> PASSIVE = Set.of(
|
||||
EntityType.ALLAY, EntityType.ARMADILLO, EntityType.AXOLOTL, EntityType.BAT, EntityType.CAMEL,
|
||||
EntityType.CAT, EntityType.CHICKEN, EntityType.COD, EntityType.COPPER_GOLEM, EntityType.COW,
|
||||
@@ -49,6 +50,10 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
EntityType.SKELETON_HORSE, EntityType.SNIFFER, EntityType.SNOW_GOLEM, EntityType.SQUID,
|
||||
EntityType.STRIDER, EntityType.TADPOLE, EntityType.TROPICAL_FISH, EntityType.TURTLE,
|
||||
EntityType.VILLAGER, EntityType.WANDERING_TRADER, EntityType.ZOMBIE_HORSE);
|
||||
private static final Set<EntityType> NEUTRAL = Set.of(
|
||||
EntityType.BEE, EntityType.CAVE_SPIDER, EntityType.DOLPHIN, EntityType.ENDERMAN, EntityType.GOAT,
|
||||
EntityType.IRON_GOLEM, EntityType.LLAMA, EntityType.PANDA, EntityType.PIGLIN, EntityType.POLAR_BEAR,
|
||||
EntityType.SPIDER, EntityType.TRADER_LLAMA, EntityType.WOLF, EntityType.ZOMBIFIED_PIGLIN);
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final ProgressionRules progression;
|
||||
@@ -60,31 +65,51 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
private final Consumer<Failure> failures;
|
||||
private final CombatSources sources;
|
||||
private final CombatContributions contributions;
|
||||
private final PartyRoster parties;
|
||||
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
|
||||
private final java.util.random.RandomGenerator random;
|
||||
private final Map<PlayerDeathEvent, Defeat> defeats = new IdentityHashMap<>();
|
||||
private final Set<UUID> pvpDefeats = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
private final Map<UUID, PendingAward> pending = new ConcurrentHashMap<>();
|
||||
private final java.util.concurrent.ConcurrentLinkedQueue<UUID> retries = new java.util.concurrent.ConcurrentLinkedQueue<>();
|
||||
private org.bukkit.scheduler.BukkitTask retryTask;
|
||||
private boolean closed;
|
||||
|
||||
private final class PendingAward {
|
||||
final UUID id = UUID.randomUUID();
|
||||
final UUID id;
|
||||
final UUID character;
|
||||
final BigInteger reward;
|
||||
final BigInteger experience;
|
||||
final BigInteger coins;
|
||||
final CombatRewardRules.KillPosition restricted;
|
||||
BigInteger expectedRevision;
|
||||
boolean running;
|
||||
PendingAward(UUID character, BigInteger reward, CombatRewardRules.KillPosition restricted) {
|
||||
this.character = character; this.reward = reward; this.restricted = restricted;
|
||||
PendingAward(UUID id, UUID character, BigInteger experience, BigInteger coins,
|
||||
CombatRewardRules.KillPosition restricted) {
|
||||
this.id = id; this.character = character; this.experience = experience;
|
||||
this.coins = coins; this.restricted = restricted;
|
||||
}
|
||||
}
|
||||
|
||||
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
|
||||
EquipmentSessions sessions, Clock clock, Consumer<Runnable> nextTick,
|
||||
Function<UUID, Player> players, Consumer<Failure> failures) {
|
||||
this(characters, progression, rewards, sessions, new PartyRoster(clock, id -> {
|
||||
var player = players.apply(id); return player != null && player.isOnline();
|
||||
}), games.dmg.fantasystick.progression.CoinRules::defaults, java.util.random.RandomGenerator.getDefault(),
|
||||
clock, nextTick, players, failures);
|
||||
}
|
||||
|
||||
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
|
||||
EquipmentSessions sessions, PartyRoster parties,
|
||||
java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules,
|
||||
java.util.random.RandomGenerator random, Clock clock, Consumer<Runnable> nextTick,
|
||||
Function<UUID, Player> players, Consumer<Failure> failures) {
|
||||
this.characters = Objects.requireNonNull(characters); this.progression = Objects.requireNonNull(progression);
|
||||
this.rewards = Objects.requireNonNull(rewards); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.players = Objects.requireNonNull(players); this.failures = Objects.requireNonNull(failures);
|
||||
this.parties = Objects.requireNonNull(parties); this.coinRules = Objects.requireNonNull(coinRules);
|
||||
this.random = Objects.requireNonNull(random); this.clock = Objects.requireNonNull(clock);
|
||||
this.nextTick = Objects.requireNonNull(nextTick); this.players = Objects.requireNonNull(players);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
sources = new CombatSources(sessions); contributions = new CombatContributions(rewards);
|
||||
}
|
||||
|
||||
@@ -122,20 +147,33 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
if (closed) return;
|
||||
var active = sessions.active(event.getEntity().getUniqueId()).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
if (record != null) defeats.put(event, new Defeat(active, record.level()));
|
||||
if (record != null) {
|
||||
defeats.put(event, new Defeat(active, record.level(), record.coins()));
|
||||
if (contributions.hasDirect(event.getEntity().getUniqueId(), record.id(), clock.instant())) {
|
||||
pvpDefeats.add(event.getEntity().getUniqueId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void playerDeath(PlayerDeathEvent event) {
|
||||
var defeat = defeats.remove(event);
|
||||
if (closed || defeat == null || !event.isCancelled() || sessions.current(defeat.active())) return;
|
||||
award(event.getEntity(), CombatRewardRules.Target.FANTASY_CHARACTER, defeat.level(), defeat.active().character());
|
||||
if (closed || defeat == null) return;
|
||||
if (!event.isCancelled() || sessions.current(defeat.active())) {
|
||||
pvpDefeats.remove(event.getEntity().getUniqueId());
|
||||
return;
|
||||
}
|
||||
award(event.getEntity(), CombatRewardRules.Target.FANTASY_CHARACTER,
|
||||
defeat.level(), defeat.active().character(), defeat.coins());
|
||||
}
|
||||
|
||||
/** Consumed synchronously by the fantasy defeat transition before it persists the Tail state. */
|
||||
public boolean consumePvpDefeat(UUID player) { return pvpDefeats.remove(player); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void mobDeath(EntityDeathEvent event) {
|
||||
if (closed || event instanceof PlayerDeathEvent) return;
|
||||
award(event.getEntity(), classify(event.getEntity()), 0, null);
|
||||
award(event.getEntity(), classify(event.getEntity()), 0, null, BigInteger.ZERO);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
@@ -145,21 +183,60 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private void award(LivingEntity target, CombatRewardRules.Target kind, int level, UUID defeatedCharacter) {
|
||||
private void award(LivingEntity target, CombatRewardRules.Target kind, int level, UUID defeatedCharacter,
|
||||
BigInteger defeatedCoins) {
|
||||
Instant now = clock.instant();
|
||||
var contributors = new ArrayList<>(contributions.consume(target.getUniqueId(), now));
|
||||
if (defeatedCharacter != null) contributors.remove(defeatedCharacter);
|
||||
if (contributors.isEmpty()) return;
|
||||
BigInteger share = rewards.share(rewards.pool(kind, level), contributors.size());
|
||||
var direct = new ArrayList<>(contributions.consumeDirect(target.getUniqueId(), now));
|
||||
if (defeatedCharacter != null) direct.remove(defeatedCharacter);
|
||||
if (direct.isEmpty()) return;
|
||||
var units = rewardUnits(direct);
|
||||
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 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());
|
||||
for (UUID character : contributors) {
|
||||
var operation = new PendingAward(character, share, restricted);
|
||||
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);
|
||||
pending.put(operation.id, operation);
|
||||
attempt(operation);
|
||||
}
|
||||
}
|
||||
|
||||
private java.util.List<games.dmg.fantasystick.progression.RewardAllocator.Unit<UUID>> rewardUnits(
|
||||
java.util.List<UUID> direct) {
|
||||
var units = new java.util.LinkedHashMap<Object, games.dmg.fantasystick.progression.RewardAllocator.Unit<UUID>>();
|
||||
direct.stream().sorted().forEach(character -> eligible(character).ifPresent(attacker -> {
|
||||
var membership = parties.rewardMembership(attacker.player());
|
||||
if (membership.isEmpty()) {
|
||||
units.putIfAbsent(character, new games.dmg.fantasystick.progression.RewardAllocator.Unit<>(character,
|
||||
java.util.List.of(character)));
|
||||
return;
|
||||
}
|
||||
var group = membership.orElseThrow();
|
||||
var recipients = group.activePlayers().stream().map(player -> sessions.active(player).orElse(null))
|
||||
.filter(Objects::nonNull).filter(sessions::current)
|
||||
.filter(active -> living(active.player())).map(EquipmentSessions.Active::character).distinct().sorted().toList();
|
||||
if (!recipients.isEmpty()) units.putIfAbsent(group.key(),
|
||||
new games.dmg.fantasystick.progression.RewardAllocator.Unit<>(group.key(), recipients));
|
||||
}));
|
||||
return java.util.List.copyOf(units.values());
|
||||
}
|
||||
|
||||
private java.util.Optional<Eligible> eligible(UUID character) {
|
||||
var active = sessions.activeCharacter(character).orElse(null);
|
||||
if (active == null || !sessions.current(active) || !living(active.player())) return java.util.Optional.empty();
|
||||
return java.util.Optional.of(new Eligible(character, active.player()));
|
||||
}
|
||||
|
||||
private boolean living(UUID playerId) {
|
||||
var player = players.apply(playerId);
|
||||
return player != null; // The current active live-record session is the online/living authority.
|
||||
}
|
||||
|
||||
private static CombatRewardRules.KillPosition position(Location location) {
|
||||
var world = Objects.requireNonNull(location.getWorld(), "Kill location has no world").getUID();
|
||||
return new CombatRewardRules.KillPosition(Objects.requireNonNull(world, "Kill world has no UUID"),
|
||||
@@ -181,8 +258,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
});
|
||||
return;
|
||||
}
|
||||
characters.awardCombatExperience(operation.character, operation.expectedRevision, operation.reward,
|
||||
progression, rewards, operation.restricted).whenComplete((award, failure) -> {
|
||||
characters.awardCombatReward(operation.character, operation.expectedRevision, operation.id,
|
||||
operation.experience, operation.coins, progression, rewards, operation.restricted).whenComplete((award, failure) -> {
|
||||
synchronized (operation) { operation.running = false; }
|
||||
if (failure != null) { fail(operation, failure); return; }
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
|
||||
@@ -223,7 +300,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
Player player = active == null ? null : players.apply(active.player());
|
||||
if (player == null || !player.isOnline()) return;
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.APPLIED) {
|
||||
player.sendMessage("Earned " + operation.reward + " fantasy XP.");
|
||||
if (operation.experience.signum() > 0) player.sendMessage("Earned " + operation.experience + " fantasy XP.");
|
||||
for (int gained = 1; gained <= award.levels(); gained++) {
|
||||
int level = saved.level() - award.levels() + gained;
|
||||
player.sendMessage("Level " + level + "! Gained 1 character point.");
|
||||
@@ -237,14 +314,27 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
} else if (award.status() == CharacterStorage.CombatAwardStatus.UNCHANGED) {
|
||||
player.sendMessage("No fantasy XP: this target's configured reward is zero.");
|
||||
}
|
||||
if (operation.coins.signum() > 0 && award.status() != CharacterStorage.CombatAwardStatus.DUPLICATE) {
|
||||
player.sendMessage("Earned " + operation.coins + " character coins.");
|
||||
}
|
||||
}
|
||||
|
||||
static CombatRewardRules.Target classify(LivingEntity entity) {
|
||||
public static CombatRewardRules.Target classify(LivingEntity entity) {
|
||||
EntityType type = entity.getType();
|
||||
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON) return CombatRewardRules.Target.BOSS;
|
||||
return PASSIVE.contains(type) ? CombatRewardRules.Target.PASSIVE : CombatRewardRules.Target.ORDINARY;
|
||||
}
|
||||
|
||||
public static games.dmg.fantasystick.progression.CoinRules.Target coinTarget(LivingEntity entity) {
|
||||
EntityType type = entity.getType();
|
||||
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON || type == EntityType.WITHER) {
|
||||
return games.dmg.fantasystick.progression.CoinRules.Target.BOSS;
|
||||
}
|
||||
if (PASSIVE.contains(type)) return games.dmg.fantasystick.progression.CoinRules.Target.PASSIVE;
|
||||
if (NEUTRAL.contains(type)) return games.dmg.fantasystick.progression.CoinRules.Target.NEUTRAL;
|
||||
return games.dmg.fantasystick.progression.CoinRules.Target.HOSTILE;
|
||||
}
|
||||
|
||||
private void report(UUID character, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, failure)); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics never replay or acknowledge a reward. */ }
|
||||
@@ -253,6 +343,6 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
if (retryTask != null) retryTask.cancel();
|
||||
defeats.clear(); pending.clear(); retries.clear(); contributions.close();
|
||||
defeats.clear(); pvpDefeats.clear(); pending.clear(); retries.clear(); contributions.close();
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ public final class PartyRoster implements AutoCloseable {
|
||||
public Party { members = List.copyOf(members); }
|
||||
}
|
||||
public record Invitation(UUID id, UUID party, UUID leader, UUID invitee, Instant expires) { }
|
||||
/** Opaque roster-key boundary used by rewards so a future character-keyed roster can replace this model. */
|
||||
public record RewardMembership(Object key, List<UUID> activePlayers) {
|
||||
public RewardMembership { Objects.requireNonNull(key); activePlayers = List.copyOf(activePlayers); }
|
||||
}
|
||||
private final Map<UUID, Party> membership = new HashMap<>();
|
||||
private final Map<UUID, Invitation> invitations = new HashMap<>();
|
||||
private final Set<UUID> departed = new HashSet<>();
|
||||
@@ -46,6 +50,11 @@ public final class PartyRoster implements AutoCloseable {
|
||||
return Optional.of(party);
|
||||
}
|
||||
public Optional<Party> partyOf(UUID player) { return Optional.ofNullable(membership.get(player)); }
|
||||
public Optional<RewardMembership> rewardMembership(UUID player) {
|
||||
var party = membership.get(player);
|
||||
if (party == null) return Optional.empty();
|
||||
return Optional.of(new RewardMembership(party.id(), party.members().stream().filter(this::connected).toList()));
|
||||
}
|
||||
public List<Invitation> invitationsFor(UUID player) {
|
||||
expire();
|
||||
return invitations.values().stream().filter(i -> i.invitee().equals(player))
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Current-session native Night Vision I layer for the learned character passive. */
|
||||
public final class LowLightVision implements AutoCloseable {
|
||||
public static final String ID = "low_light_vision";
|
||||
private final PersistentPotionEffect effect;
|
||||
|
||||
public LowLightVision(EquipmentSessions sessions) {
|
||||
effect = new PersistentPotionEffect(ID, PotionEffectType.NIGHT_VISION, 0, false, sessions);
|
||||
}
|
||||
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Low-Light Vision", Map.of(Stat.WIS, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE,
|
||||
List.of("Continuously provides native Night Vision I while this character is active.",
|
||||
"Preserves unrelated Night Vision and does not change light, darkness or mob behavior."));
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
|
||||
public void sweep() { effect.sweep(); }
|
||||
@Override public void close() { effect.close(); }
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Bounded current-character ownership for one persistent native potion layer. */
|
||||
final class PersistentPotionEffect implements AutoCloseable {
|
||||
private static final int MAX_ACTIVE_CHECKS = 256;
|
||||
|
||||
private final class State {
|
||||
final EquipmentSessions.Active active;
|
||||
final Player player;
|
||||
PotionEffect owned;
|
||||
PotionEffect displaced;
|
||||
long displacedUntil;
|
||||
long refreshedAt;
|
||||
State(EquipmentSessions.Active active, Player player) { this.active = active; this.player = player; }
|
||||
|
||||
void refresh() {
|
||||
refreshedAt = sweepGeneration;
|
||||
if (displaced != null && !displaced.isInfinite() && sweepGeneration >= displacedUntil) displaced = null;
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(type); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (owned != null && !same(current, owned)) { owned = null; displaced = null; }
|
||||
if (!eligible()) { remove(); return; }
|
||||
if (current != null && (!replaceWeaker || current.getAmplifier() >= amplifier)) return;
|
||||
var effect = new PotionEffect(type, PotionEffect.INFINITE_DURATION, amplifier, false, false, true);
|
||||
try {
|
||||
if (player.addPotionEffect(effect, true)) {
|
||||
owned = effect; displaced = current;
|
||||
displacedUntil = current == null ? 0 : current.isInfinite()
|
||||
? Long.MAX_VALUE : sweepGeneration + current.getDuration();
|
||||
}
|
||||
} catch (RuntimeException unavailable) { owned = null; displaced = null; }
|
||||
}
|
||||
|
||||
boolean eligible() {
|
||||
if (closed || !sessions.current(active)) return false;
|
||||
var record = sessions.currentRecord(active).orElse(null);
|
||||
return record != null && !record.dead() && record.passives().contains(passive);
|
||||
}
|
||||
|
||||
void remove() {
|
||||
if (owned != null) {
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(type); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (same(current, owned)) {
|
||||
try {
|
||||
player.removePotionEffect(type);
|
||||
if (displaced != null && (displaced.isInfinite() || sweepGeneration < displacedUntil)) {
|
||||
int duration = displaced.isInfinite() ? PotionEffect.INFINITE_DURATION
|
||||
: Math.toIntExact(Math.min(Integer.MAX_VALUE, displacedUntil - sweepGeneration));
|
||||
player.addPotionEffect(new PotionEffect(displaced.getType(), duration, displaced.getAmplifier(),
|
||||
displaced.isAmbient(), displaced.hasParticles(), displaced.hasIcon()), true);
|
||||
}
|
||||
} catch (RuntimeException unavailable) { return; }
|
||||
}
|
||||
owned = null; displaced = null;
|
||||
}
|
||||
states.remove(active.player(), this);
|
||||
}
|
||||
}
|
||||
|
||||
private final String passive;
|
||||
private final PotionEffectType type;
|
||||
private final int amplifier;
|
||||
private final boolean replaceWeaker;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Map<UUID, State> states = new java.util.HashMap<>();
|
||||
private int activeCursor;
|
||||
private int stateCursor;
|
||||
private long sweepGeneration;
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
PersistentPotionEffect(String passive, PotionEffectType type, int amplifier, boolean replaceWeaker,
|
||||
EquipmentSessions sessions) {
|
||||
this.passive = Objects.requireNonNull(passive); this.type = Objects.requireNonNull(type);
|
||||
if (passive.isBlank() || amplifier < 0) throw new IllegalArgumentException("Invalid persistent potion effect");
|
||||
this.amplifier = amplifier; this.replaceWeaker = replaceWeaker; this.sessions = Objects.requireNonNull(sessions);
|
||||
}
|
||||
|
||||
void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin);
|
||||
if (closed || sweepTask != null) throw new IllegalStateException("Passive potion sweep already installed or closed");
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
|
||||
void sweep() {
|
||||
if (closed) return;
|
||||
sweepGeneration++;
|
||||
var active = sessions.activeCharacters();
|
||||
int activeChecks = Math.min(MAX_ACTIVE_CHECKS, active.size());
|
||||
for (int index = 0; index < activeChecks; index++) {
|
||||
var snapshot = active.get((activeCursor + index) % active.size());
|
||||
reconcile(snapshot);
|
||||
}
|
||||
activeCursor = active.isEmpty() ? 0 : (activeCursor + activeChecks) % active.size();
|
||||
|
||||
var known = List.copyOf(states.values());
|
||||
int stateChecks = Math.min(MAX_ACTIVE_CHECKS, known.size());
|
||||
for (int index = 0; index < stateChecks; index++) {
|
||||
var state = known.get((stateCursor + index) % known.size());
|
||||
if (state.refreshedAt != sweepGeneration) state.refresh();
|
||||
}
|
||||
stateCursor = known.isEmpty() ? 0 : (stateCursor + stateChecks) % known.size();
|
||||
}
|
||||
|
||||
private void reconcile(EquipmentSessions.ActiveCharacter snapshot) {
|
||||
var active = snapshot.session();
|
||||
var state = states.get(active.player());
|
||||
boolean learned = !snapshot.record().dead() && snapshot.record().passives().contains(passive);
|
||||
if (!learned || !sessions.current(active)) { if (state != null) state.refresh(); return; }
|
||||
if (state != null && !state.active.equals(active)) { state.remove(); state = states.get(active.player()); }
|
||||
if (state == null) {
|
||||
var player = sessions.currentPlayer(active).orElse(null);
|
||||
if (player == null) return;
|
||||
state = new State(active, player); states.put(active.player(), state);
|
||||
}
|
||||
state.refresh();
|
||||
}
|
||||
|
||||
private static boolean same(PotionEffect first, PotionEffect second) {
|
||||
return first != null && second != null && first.getType().equals(second.getType())
|
||||
&& first.getAmplifier() == second.getAmplifier() && first.isInfinite() && second.isInfinite()
|
||||
&& first.isAmbient() == second.isAmbient() && first.hasParticles() == second.hasParticles()
|
||||
&& first.hasIcon() == second.hasIcon();
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (sweepTask != null) sweepTask.cancel();
|
||||
for (var state : List.copyOf(states.values())) state.remove();
|
||||
states.clear();
|
||||
}
|
||||
}
|
||||
@@ -5,145 +5,25 @@ import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Current-session Speed I layer for the learned character passive. */
|
||||
/** Current-session native Speed I layer for the learned character passive. */
|
||||
public final class SpeedI implements AutoCloseable {
|
||||
public static final String ID = "speed_i";
|
||||
private static final int MAX_ACTIVE_CHECKS = 256;
|
||||
private static final int AMPLIFIER = 0;
|
||||
private final PersistentPotionEffect effect;
|
||||
|
||||
private final class State {
|
||||
final EquipmentSessions.Active active;
|
||||
final Player player;
|
||||
PotionEffect owned;
|
||||
PotionEffect displaced;
|
||||
long displacedUntil;
|
||||
long refreshedAt;
|
||||
State(EquipmentSessions.Active active, Player player) { this.active = active; this.player = player; }
|
||||
|
||||
void refresh() {
|
||||
refreshedAt = sweepGeneration;
|
||||
if (displaced != null && !displaced.isInfinite() && sweepGeneration >= displacedUntil) displaced = null;
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(PotionEffectType.SPEED); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (owned != null && !same(current, owned)) { owned = null; displaced = null; }
|
||||
if (!eligible()) { remove(); return; }
|
||||
if (current != null && current.getAmplifier() >= AMPLIFIER) return;
|
||||
var effect = new PotionEffect(PotionEffectType.SPEED, PotionEffect.INFINITE_DURATION,
|
||||
AMPLIFIER, false, false, true);
|
||||
try {
|
||||
if (player.addPotionEffect(effect, true)) {
|
||||
owned = effect; displaced = current;
|
||||
displacedUntil = current == null ? 0 : current.isInfinite()
|
||||
? Long.MAX_VALUE : sweepGeneration + current.getDuration();
|
||||
}
|
||||
} catch (RuntimeException unavailable) { owned = null; displaced = null; }
|
||||
}
|
||||
|
||||
boolean eligible() {
|
||||
if (closed || !sessions.current(active)) return false;
|
||||
var record = sessions.currentRecord(active).orElse(null);
|
||||
return record != null && !record.dead() && record.passives().contains(ID);
|
||||
}
|
||||
|
||||
void remove() {
|
||||
if (owned != null) {
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(PotionEffectType.SPEED); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (same(current, owned)) {
|
||||
try {
|
||||
player.removePotionEffect(PotionEffectType.SPEED);
|
||||
if (displaced != null && (displaced.isInfinite() || sweepGeneration < displacedUntil)) {
|
||||
int duration = displaced.isInfinite() ? PotionEffect.INFINITE_DURATION
|
||||
: Math.toIntExact(Math.min(Integer.MAX_VALUE, displacedUntil - sweepGeneration));
|
||||
player.addPotionEffect(new PotionEffect(displaced.getType(), duration, displaced.getAmplifier(),
|
||||
displaced.isAmbient(), displaced.hasParticles(), displaced.hasIcon()), true);
|
||||
}
|
||||
} catch (RuntimeException unavailable) { return; }
|
||||
}
|
||||
owned = null; displaced = null;
|
||||
}
|
||||
states.remove(active.player(), this);
|
||||
}
|
||||
public SpeedI(EquipmentSessions sessions) {
|
||||
effect = new PersistentPotionEffect(ID, PotionEffectType.SPEED, 0, true, sessions);
|
||||
}
|
||||
|
||||
private final EquipmentSessions sessions;
|
||||
private final Map<UUID, State> states = new java.util.HashMap<>();
|
||||
private int activeCursor;
|
||||
private int stateCursor;
|
||||
private long sweepGeneration;
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
public SpeedI(EquipmentSessions sessions) { this.sessions = Objects.requireNonNull(sessions); }
|
||||
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Speed I", Map.of(Stat.DEX, BigInteger.valueOf(14)), Set.of(), BigInteger.ONE,
|
||||
List.of("Continuously provides the standard native Speed I effect while this character is active.",
|
||||
"Does not stack with equal Speed I and preserves stronger unrelated Speed effects."));
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin);
|
||||
if (closed || sweepTask != null) throw new IllegalStateException("Speed I sweep already installed or closed");
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
|
||||
/** Bounded rotating reconciliation for active sessions and previously owned layers. */
|
||||
public void sweep() {
|
||||
if (closed) return;
|
||||
sweepGeneration++;
|
||||
var active = sessions.activeCharacters();
|
||||
int activeChecks = Math.min(MAX_ACTIVE_CHECKS, active.size());
|
||||
for (int index = 0; index < activeChecks; index++) {
|
||||
var snapshot = active.get((activeCursor + index) % active.size());
|
||||
reconcile(snapshot);
|
||||
}
|
||||
activeCursor = active.isEmpty() ? 0 : (activeCursor + activeChecks) % active.size();
|
||||
|
||||
var known = List.copyOf(states.values());
|
||||
int stateChecks = Math.min(MAX_ACTIVE_CHECKS, known.size());
|
||||
for (int index = 0; index < stateChecks; index++) {
|
||||
var state = known.get((stateCursor + index) % known.size());
|
||||
if (state.refreshedAt != sweepGeneration) state.refresh();
|
||||
}
|
||||
stateCursor = known.isEmpty() ? 0 : (stateCursor + stateChecks) % known.size();
|
||||
}
|
||||
|
||||
private void reconcile(EquipmentSessions.ActiveCharacter snapshot) {
|
||||
var active = snapshot.session();
|
||||
var state = states.get(active.player());
|
||||
boolean learned = !snapshot.record().dead() && snapshot.record().passives().contains(ID);
|
||||
if (!learned || !sessions.current(active)) { if (state != null) state.refresh(); return; }
|
||||
if (state != null && !state.active.equals(active)) { state.remove(); state = states.get(active.player()); }
|
||||
if (state == null) {
|
||||
var player = sessions.currentPlayer(active).orElse(null);
|
||||
if (player == null) return;
|
||||
state = new State(active, player); states.put(active.player(), state);
|
||||
}
|
||||
state.refresh();
|
||||
}
|
||||
|
||||
private static boolean same(PotionEffect first, PotionEffect second) {
|
||||
return first != null && second != null && first.getType().equals(second.getType())
|
||||
&& first.getAmplifier() == second.getAmplifier() && first.isInfinite() && second.isInfinite()
|
||||
&& first.isAmbient() == second.isAmbient() && first.hasParticles() == second.hasParticles()
|
||||
&& first.hasIcon() == second.hasIcon();
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (sweepTask != null) sweepTask.cancel();
|
||||
for (var state : List.copyOf(states.values())) state.remove();
|
||||
states.clear();
|
||||
}
|
||||
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
|
||||
public void sweep() { effect.sweep(); }
|
||||
@Override public void close() { effect.close(); }
|
||||
}
|
||||
@@ -18,13 +18,22 @@ public record CharacterRecord(
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
boolean dead, UUID deathId, Instant diedAt,
|
||||
BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts, BigInteger pvpCoinEscrow) {
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills, boolean dead, UUID deathId,
|
||||
Instant diedAt, BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts) {
|
||||
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout, passives,
|
||||
cooldowns, restrictedKills, dead, deathId, diedAt, coins, coinsInitialized, rewardReceipts, BigInteger.ZERO);
|
||||
}
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout,
|
||||
List.of("", "", ""), cooldowns, Map.of(), dead, deathId, diedAt);
|
||||
List.of("", "", ""), cooldowns, Map.of(), dead, deathId, diedAt, BigInteger.ZERO, true, Set.of());
|
||||
}
|
||||
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
@@ -33,7 +42,16 @@ public record CharacterRecord(
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout,
|
||||
List.of("", "", ""), cooldowns, restrictedKills, dead, deathId, diedAt);
|
||||
List.of("", "", ""), cooldowns, restrictedKills, dead, deathId, diedAt, BigInteger.ZERO, true, Set.of());
|
||||
}
|
||||
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout, passives,
|
||||
cooldowns, restrictedKills, dead, deathId, diedAt, BigInteger.ZERO, true, Set.of());
|
||||
}
|
||||
|
||||
public CharacterRecord {
|
||||
@@ -43,12 +61,15 @@ public record CharacterRecord(
|
||||
Objects.requireNonNull(experience);
|
||||
Objects.requireNonNull(unspentPoints);
|
||||
Objects.requireNonNull(health);
|
||||
Objects.requireNonNull(coins);
|
||||
Objects.requireNonNull(pvpCoinEscrow);
|
||||
stats = Map.copyOf(stats);
|
||||
learned = Set.copyOf(learned);
|
||||
loadout = List.copyOf(loadout);
|
||||
passives = List.copyOf(passives);
|
||||
cooldowns = Map.copyOf(cooldowns);
|
||||
restrictedKills = Map.copyOf(restrictedKills);
|
||||
rewardReceipts = Set.copyOf(rewardReceipts);
|
||||
if (revision.signum() < 0 || experience.signum() < 0 || unspentPoints.signum() < 0 || level < 1
|
||||
|| !stats.keySet().equals(EnumSet.allOf(Stat.class))
|
||||
|| stats.values().stream().anyMatch(value -> value.signum() < 0)
|
||||
@@ -62,6 +83,8 @@ public record CharacterRecord(
|
||||
|| cooldowns.keySet().stream().anyMatch(String::isBlank)
|
||||
|| restrictedKills.size() > 1024
|
||||
|| restrictedKills.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(entry.getValue().world()))
|
||||
|| coins.signum() < 0 || pvpCoinEscrow.signum() < 0 || !coinsInitialized && coins.signum() != 0
|
||||
|| rewardReceipts.size() > 4096 || !dead && pvpCoinEscrow.signum() != 0
|
||||
|| (deathId == null) != (diedAt == null) || dead && deathId == null) {
|
||||
throw new IllegalArgumentException("Invalid character record");
|
||||
}
|
||||
|
||||
@@ -24,12 +24,23 @@ import java.util.concurrent.RejectedExecutionException;
|
||||
public final class CharacterStorage implements AutoCloseable {
|
||||
private final YamlCharacterRepository repository;
|
||||
private final Clock clock;
|
||||
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
|
||||
private final java.util.random.RandomGenerator coinRandom;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-character-persistence"));
|
||||
|
||||
public CharacterStorage(YamlCharacterRepository repository, Clock clock) {
|
||||
this(repository, clock, games.dmg.fantasystick.progression.CoinRules::defaults,
|
||||
java.util.random.RandomGenerator.getDefault());
|
||||
}
|
||||
|
||||
public CharacterStorage(YamlCharacterRepository repository, Clock clock,
|
||||
java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules,
|
||||
java.util.random.RandomGenerator coinRandom) {
|
||||
this.repository = Objects.requireNonNull(repository);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.coinRules = Objects.requireNonNull(coinRules);
|
||||
this.coinRandom = Objects.requireNonNull(coinRandom);
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> create(CharacterRecord character) {
|
||||
@@ -54,7 +65,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
|| record.experience().signum() != 0 || record.stats().values().stream().anyMatch(value -> value.signum() != 0)
|
||||
|| record.health().compareTo(java.math.BigDecimal.TEN) != 0
|
||||
|| !record.learned().isEmpty() || record.passives().stream().anyMatch(id -> !id.isEmpty())
|
||||
|| !record.cooldowns().isEmpty()
|
||||
|| !record.cooldowns().isEmpty() || !record.coinsInitialized() || !record.rewardReceipts().isEmpty()
|
||||
|| record.loadout().stream().anyMatch(ability -> !ability.isEmpty())) {
|
||||
throw new IllegalArgumentException("Craft batch requires distinct new characters");
|
||||
}
|
||||
@@ -193,7 +204,20 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> find(UUID id) {
|
||||
return submit(() -> repository.load(id));
|
||||
return submit(() -> load(id));
|
||||
}
|
||||
|
||||
/** Persist a legacy character's one-time configured migration roll before exposing it. */
|
||||
private Optional<CharacterRecord> load(UUID id) throws IOException {
|
||||
var found = repository.load(id);
|
||||
if (found.isEmpty() || found.orElseThrow().coinsInitialized()) return found;
|
||||
var old = found.orElseThrow();
|
||||
var migrated = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
|
||||
old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(),
|
||||
coinRules.get().starting(coinRandom), true, old.rewardReceipts());
|
||||
repository.replace(migrated, old.revision());
|
||||
return Optional.of(migrated);
|
||||
}
|
||||
|
||||
/** Persist an accepted native rename against the latest live record; never create a character from item metadata. */
|
||||
@@ -207,7 +231,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (old.name().equals(name)) return Optional.of(old);
|
||||
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
repository.replace(renamed, old.revision());
|
||||
return Optional.of(renamed);
|
||||
});
|
||||
@@ -247,29 +271,44 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
int gained = Math.max(0, level - old.level());
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
repository.replace(saved, old.revision());
|
||||
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
|
||||
});
|
||||
}
|
||||
|
||||
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
|
||||
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, DUPLICATE, STALE, UNAVAILABLE }
|
||||
public record CombatAward(CombatAwardStatus status, Optional<CharacterRecord> record, int levels) { }
|
||||
|
||||
/** Persist one combat share and its restricted-kill reference in the same character revision. */
|
||||
/** Compatibility route for callers that do not yet carry a stable reward identity or coins. */
|
||||
public CompletableFuture<CombatAward> awardCombatExperience(UUID characterId, BigInteger expectedRevision,
|
||||
BigInteger reward, ProgressionRules progression,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules rewards,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules.KillPosition restrictedKill) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision);
|
||||
Objects.requireNonNull(reward); Objects.requireNonNull(progression); Objects.requireNonNull(rewards);
|
||||
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
|
||||
return awardCombatReward(characterId, expectedRevision, UUID.randomUUID(), reward, BigInteger.ZERO,
|
||||
progression, rewards, restrictedKill);
|
||||
}
|
||||
|
||||
/** Persist one idempotent XP/coin recipient share, receipt and spacing reference in one revision. */
|
||||
public CompletableFuture<CombatAward> awardCombatReward(UUID characterId, BigInteger expectedRevision, UUID rewardId,
|
||||
BigInteger experienceReward, BigInteger coinReward, ProgressionRules progression,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules rewards,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules.KillPosition restrictedKill) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(rewardId);
|
||||
Objects.requireNonNull(experienceReward); Objects.requireNonNull(coinReward);
|
||||
Objects.requireNonNull(progression); Objects.requireNonNull(rewards);
|
||||
if (experienceReward.signum() < 0 || coinReward.signum() < 0) {
|
||||
throw new IllegalArgumentException("Combat rewards must not be negative");
|
||||
}
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new CombatAward(CombatAwardStatus.UNAVAILABLE, Optional.empty(), 0);
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (old.rewardReceipts().contains(rewardId)) {
|
||||
return new CombatAward(CombatAwardStatus.DUPLICATE, Optional.of(old), 0);
|
||||
}
|
||||
if (!old.revision().equals(expectedRevision)) {
|
||||
return new CombatAward(CombatAwardStatus.STALE, Optional.of(old), 0);
|
||||
}
|
||||
@@ -279,17 +318,19 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (restrictedKill != null) history.put(restrictedKill.world(), restrictedKill);
|
||||
var cap = progression.xpPerLevel().multiply(BigInteger.valueOf(progression.maximumLevel() - 1L));
|
||||
boolean atCap = old.level() >= progression.maximumLevel() || old.experience().compareTo(cap) >= 0;
|
||||
var experience = eligible && !atCap ? old.experience().add(reward).min(cap) : old.experience();
|
||||
var experience = eligible && !atCap ? old.experience().add(experienceReward).min(cap) : old.experience();
|
||||
int level = atCap ? old.level() : experience.divide(progression.xpPerLevel()).intValueExact() + 1;
|
||||
int gained = Math.max(0, level - old.level());
|
||||
var status = !eligible ? CombatAwardStatus.SPACING_BLOCKED : atCap ? CombatAwardStatus.AT_CAP
|
||||
: experience.equals(old.experience()) ? CombatAwardStatus.UNCHANGED : CombatAwardStatus.APPLIED;
|
||||
if (history.equals(old.restrictedKills()) && experience.equals(old.experience())) {
|
||||
return new CombatAward(status, Optional.of(old), 0);
|
||||
}
|
||||
: experience.equals(old.experience()) && coinReward.signum() == 0
|
||||
? CombatAwardStatus.UNCHANGED : CombatAwardStatus.APPLIED;
|
||||
var receipts = new java.util.HashSet<>(old.rewardReceipts());
|
||||
if (receipts.size() >= 4096) receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());
|
||||
receipts.add(rewardId);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.passives(), old.cooldowns(), history, false, old.deathId(), old.diedAt());
|
||||
old.loadout(), old.passives(), old.cooldowns(), history, false, old.deathId(), old.diedAt(),
|
||||
old.coins().add(coinReward), true, receipts);
|
||||
repository.replace(saved, old.revision());
|
||||
return new CombatAward(status, Optional.of(saved), gained);
|
||||
});
|
||||
@@ -329,7 +370,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var passives = new java.util.ArrayList<>(old.passives()); passives.set(slot, passive);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
repository.replace(saved, old.revision());
|
||||
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -357,7 +398,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var passives = old.passives().stream().map(id -> id.equals(passive) ? "" : id).toList();
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
repository.replace(saved, old.revision());
|
||||
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -392,7 +433,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var loadout = new java.util.ArrayList<>(old.loadout()); loadout.set(slot, ability);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -412,7 +453,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var loadout = old.loadout().stream().map(assigned -> assigned.equals(ability) ? "" : assigned).toList();
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -435,7 +476,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (loadout.equals(old.loadout())) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -459,7 +500,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var cooldowns = new java.util.HashMap<>(old.cooldowns()); cooldowns.put(ability, readyAt);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), cooldowns,
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -504,7 +545,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
|
||||
currentHealth.add(gain), old.learned(), old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false,
|
||||
old.deathId(), old.diedAt());
|
||||
old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
repository.replace(saved, old.revision());
|
||||
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -530,7 +571,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
repository.replace(saved, old.revision());
|
||||
return Optional.of(saved);
|
||||
});
|
||||
@@ -543,14 +584,18 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
* Callers must retain their one-use session authority until the result resolves.
|
||||
*/
|
||||
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId) {
|
||||
return death(characterId, null);
|
||||
return death(characterId, null, false);
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId, BigInteger expectedRevision) {
|
||||
return death(characterId, Objects.requireNonNull(expectedRevision));
|
||||
return death(characterId, Objects.requireNonNull(expectedRevision), false);
|
||||
}
|
||||
|
||||
private CompletableFuture<Optional<CharacterRecord>> death(UUID characterId, BigInteger expectedRevision) {
|
||||
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId, boolean loseCoins) {
|
||||
return death(characterId, null, loseCoins);
|
||||
}
|
||||
|
||||
private CompletableFuture<Optional<CharacterRecord>> death(UUID characterId, BigInteger expectedRevision, boolean loseCoins) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Instant diedAt = clock.instant();
|
||||
UUID deathId = UUID.randomUUID();
|
||||
@@ -561,7 +606,9 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (old.dead() || expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
|
||||
var dead = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.ZERO, old.learned(),
|
||||
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt);
|
||||
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt,
|
||||
loseCoins ? BigInteger.ZERO : old.coins(), old.coinsInitialized(), old.rewardReceipts(),
|
||||
loseCoins ? old.coins() : BigInteger.ZERO);
|
||||
repository.replace(dead, old.revision());
|
||||
return Optional.of(dead);
|
||||
});
|
||||
@@ -598,7 +645,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var health = nativeLimit == null ? old.maximumHealth() : old.maximumHealth().min(nativeLimit);
|
||||
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
repository.replace(restored, old.revision());
|
||||
return Optional.of(restored);
|
||||
});
|
||||
|
||||
@@ -54,6 +54,10 @@ final class CharacterYaml {
|
||||
"y", position.y().toString(), "z", position.z().toString()));
|
||||
});
|
||||
root.put("restricted-kills", restrictedKills);
|
||||
root.put("coins-initialized", record.coinsInitialized());
|
||||
root.put("coins", record.coins().toString());
|
||||
root.put("reward-receipts", new ArrayList<>(record.rewardReceipts().stream().sorted().map(UUID::toString).toList()));
|
||||
root.put("pvp-coin-escrow", record.pvpCoinEscrow().toString());
|
||||
root.put("dead", record.dead());
|
||||
if (record.deathId() != null) {
|
||||
root.put("death-id", record.deathId().toString());
|
||||
@@ -97,7 +101,12 @@ final class CharacterYaml {
|
||||
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")),
|
||||
root.containsKey("learned-passives") ? strings(root.get("learned-passives")) : List.of("", "", ""),
|
||||
cooldowns, restrictedKills, dead, root.containsKey("death-id") ? UUID.fromString(text(root.get("death-id"))) : null,
|
||||
root.containsKey("died-at") ? instant(root.get("died-at")) : null);
|
||||
root.containsKey("died-at") ? instant(root.get("died-at")) : null,
|
||||
root.containsKey("coins") ? number(root.get("coins")) : BigInteger.ZERO,
|
||||
root.containsKey("coins-initialized") && bool(root.get("coins-initialized")),
|
||||
root.containsKey("reward-receipts") ? strings(root.get("reward-receipts")).stream()
|
||||
.map(UUID::fromString).collect(java.util.stream.Collectors.toUnmodifiableSet()) : java.util.Set.of(),
|
||||
root.containsKey("pvp-coin-escrow") ? number(root.get("pvp-coin-escrow")) : BigInteger.ZERO);
|
||||
}
|
||||
|
||||
private static Map<?, ?> object(Object value) {
|
||||
@@ -114,6 +123,11 @@ final class CharacterYaml {
|
||||
return list(value).stream().map(CharacterYaml::text).toList();
|
||||
}
|
||||
|
||||
private static boolean bool(Object value) {
|
||||
if (value instanceof Boolean result) return result;
|
||||
throw new IllegalArgumentException("Expected YAML boolean");
|
||||
}
|
||||
|
||||
private static String text(Object value) {
|
||||
if (value instanceof String string) return string;
|
||||
throw new IllegalArgumentException("Expected YAML string");
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.progression.CoinRules;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publication inclusive coin ranges. */
|
||||
public final class CoinSettings implements AutoCloseable {
|
||||
public static final String START_MIN="starting-coins-min", START_MAX="starting-coins-max";
|
||||
public static final String HOSTILE_MIN="hostile-coin-drop-min", HOSTILE_MAX="hostile-coin-drop-max";
|
||||
public static final String BOSS_MIN="boss-coin-drop-min", BOSS_MAX="boss-coin-drop-max";
|
||||
private static final List<String> KEYS=List.of(START_MIN,START_MAX,HOSTILE_MIN,HOSTILE_MAX,BOSS_MIN,BOSS_MAX);
|
||||
private static final Map<String,String> DEFAULTS=Map.of(START_MIN,"1",START_MAX,"6",HOSTILE_MIN,"1",HOSTILE_MAX,"5",BOSS_MIN,"25",BOSS_MAX,"50");
|
||||
public record Update(String key,String value,CoinRules rules,Map<String,String> values,long sequence){public Update{values=Map.copyOf(values);}}
|
||||
private final Path directory;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-coin-configuration"));
|
||||
private volatile Map<String,String> effective;
|
||||
private Map<String,String> persisted;
|
||||
private long savedSequence,publishedSequence;
|
||||
private boolean closed;
|
||||
|
||||
public CoinSettings(Path directory,Map<String,String> initial){
|
||||
this.directory=Objects.requireNonNull(directory).toAbsolutePath();
|
||||
var values=new LinkedHashMap<String,String>();
|
||||
for(String key:KEYS)values.put(key,Objects.requireNonNull(initial.get(key)));
|
||||
parse(values);effective=Map.copyOf(values);persisted=effective;
|
||||
}
|
||||
public static List<String> keys(){return KEYS;}
|
||||
public static String defaultValue(String key){return DEFAULTS.get(key);}
|
||||
public String value(String key){return effective.get(key);}
|
||||
public CoinRules rules(){return parse(effective);}
|
||||
public static CoinRules parse(Map<String,String> values){
|
||||
try{return new CoinRules(whole(values.get(START_MIN)),whole(values.get(START_MAX)),whole(values.get(HOSTILE_MIN)),whole(values.get(HOSTILE_MAX)),whole(values.get(BOSS_MIN)),whole(values.get(BOSS_MAX)));}
|
||||
catch(RuntimeException invalid){throw new IllegalArgumentException("Coin settings must be ordered whole numbers from 0 through 2147483647",invalid);}
|
||||
}
|
||||
private static long whole(String value){var number=new java.math.BigInteger(value);if(number.signum()<0||number.compareTo(java.math.BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException();return number.longValueExact();}
|
||||
public CompletionStage<Update> set(String key,String raw){
|
||||
if(!KEYS.contains(key))throw new IllegalArgumentException("Unknown coin setting");
|
||||
String accepted;try{accepted=new java.math.BigInteger(raw).toString();}catch(RuntimeException invalid){throw new IllegalArgumentException("Coin setting must be a whole number",invalid);}
|
||||
var preview=new LinkedHashMap<>(effective);preview.put(key,accepted);parse(preview);
|
||||
synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration closed"));}
|
||||
return CompletableFuture.supplyAsync(()->{try{var next=new LinkedHashMap<>(persisted);next.put(key,accepted);var rules=parse(next);save(key,accepted);persisted=Map.copyOf(next);return new Update(key,accepted,rules,persisted,++savedSequence);}catch(IOException failure){throw new CompletionException(failure);}},worker).minimalCompletionStage();
|
||||
}
|
||||
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;parse(update.values());effective=update.values();publishedSequence=update.sequence();return true;}
|
||||
private void save(String key,String value)throws IOException{
|
||||
Files.createDirectories(directory);
|
||||
var file=directory.resolve("config.yml");
|
||||
var lockFile=directory.resolve(".config.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(1_048_576);
|
||||
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(1_048_577);}if(bytes.length>1_048_576)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded instanceof Map<?,?> map)for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String savedKey))throw new IOException("Invalid key");document.put(savedKey,entry.getValue());}}
|
||||
document.put(key,value);byte[] bytes=yaml.dump(document).getBytes(StandardCharsets.UTF_8);Path temporary=Files.createTempFile(directory,".config-",".tmp");try{Files.write(temporary,bytes,StandardOpenOption.TRUNCATE_EXISTING);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){output.force(true);}Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}finally{Files.deleteIfExists(temporary);}
|
||||
}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy",busy);}
|
||||
}
|
||||
@Override public synchronized void close(){closed=true;worker.shutdown();}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Objects;
|
||||
import java.util.random.RandomGenerator;
|
||||
|
||||
/** Reviewed inclusive coin ranges and target classification, independent of fantasy XP. */
|
||||
public record CoinRules(long startingMin, long startingMax, long hostileMin, long hostileMax,
|
||||
long bossMin, long bossMax) {
|
||||
public enum Target { PASSIVE, NEUTRAL, HOSTILE, BOSS }
|
||||
private static final long MAX = Integer.MAX_VALUE;
|
||||
|
||||
public CoinRules {
|
||||
validate(startingMin, startingMax);
|
||||
validate(hostileMin, hostileMax);
|
||||
validate(bossMin, bossMax);
|
||||
}
|
||||
|
||||
public static CoinRules defaults() { return new CoinRules(1, 6, 1, 5, 25, 50); }
|
||||
|
||||
public BigInteger starting(RandomGenerator random) {
|
||||
return roll(startingMin, startingMax, random);
|
||||
}
|
||||
|
||||
public BigInteger mob(Target target, RandomGenerator random) {
|
||||
Objects.requireNonNull(target);
|
||||
return switch (target) {
|
||||
case PASSIVE, NEUTRAL -> BigInteger.ZERO;
|
||||
case HOSTILE -> roll(hostileMin, hostileMax, random);
|
||||
case BOSS -> roll(bossMin, bossMax, random);
|
||||
};
|
||||
}
|
||||
|
||||
private static BigInteger roll(long minimum, long maximum, RandomGenerator random) {
|
||||
Objects.requireNonNull(random);
|
||||
long value = minimum + random.nextLong(maximum - minimum + 1);
|
||||
return BigInteger.valueOf(value);
|
||||
}
|
||||
|
||||
private static void validate(long minimum, long maximum) {
|
||||
if (minimum < 0 || maximum < minimum || maximum > MAX) {
|
||||
throw new IllegalArgumentException("Coin ranges must be ordered whole numbers from 0 through 2147483647");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,28 @@ public final class CombatContributions implements AutoCloseable {
|
||||
.merge(supportingCharacter, acceptedAt, (previous, next) -> previous.isAfter(next) ? previous : next);
|
||||
}
|
||||
|
||||
public boolean hasDirect(UUID target, UUID excludedCharacter, Instant at) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(at);
|
||||
if (closed) return false;
|
||||
var direct = targets.get(target);
|
||||
return direct != null && direct.entrySet().stream().anyMatch(entry ->
|
||||
!entry.getKey().equals(excludedCharacter) && rules.contributes(entry.getValue(), at));
|
||||
}
|
||||
|
||||
/** Consume only direct attackers; support effects no longer establish reward eligibility. */
|
||||
public Set<UUID> consumeDirect(UUID target, Instant deathAt) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(deathAt);
|
||||
if (closed) return Set.of();
|
||||
var contributions = targets.remove(target);
|
||||
supports.remove(target);
|
||||
if (contributions == null) return Set.of();
|
||||
var eligible = new LinkedHashSet<UUID>();
|
||||
contributions.forEach((character, acceptedAt) -> {
|
||||
if (rules.contributes(acceptedAt, deathAt)) eligible.add(character);
|
||||
});
|
||||
return Set.copyOf(eligible);
|
||||
}
|
||||
|
||||
public Set<UUID> consume(UUID target, Instant deathAt) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(deathAt);
|
||||
if (closed) return Set.of();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.random.RandomGenerator;
|
||||
|
||||
/** Exact two-stage quotient/remainder allocation; spacing and caps apply only after conservation. */
|
||||
public final class RewardAllocator {
|
||||
private RewardAllocator() { }
|
||||
|
||||
public record Unit<T>(Object key, List<T> recipients) {
|
||||
public Unit {
|
||||
Objects.requireNonNull(key);
|
||||
recipients = List.copyOf(recipients);
|
||||
if (recipients.isEmpty() || recipients.stream().anyMatch(Objects::isNull)
|
||||
|| recipients.size() != new HashSet<>(recipients).size()) {
|
||||
throw new IllegalArgumentException("A reward unit needs distinct recipients");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> Map<T, BigInteger> allocate(BigInteger pool, List<Unit<T>> units, RandomGenerator random) {
|
||||
Objects.requireNonNull(pool); Objects.requireNonNull(units); Objects.requireNonNull(random);
|
||||
if (pool.signum() < 0 || units.isEmpty()) throw new IllegalArgumentException("Invalid reward pool or units");
|
||||
var keys = new HashSet<Object>();
|
||||
var recipients = new HashSet<T>();
|
||||
for (var unit : units) {
|
||||
if (!keys.add(unit.key()) || unit.recipients().stream().anyMatch(recipient -> !recipients.add(recipient))) {
|
||||
throw new IllegalArgumentException("Reward units and recipients must be distinct");
|
||||
}
|
||||
}
|
||||
var unitShares = split(pool, units.size(), random);
|
||||
var result = new LinkedHashMap<T, BigInteger>();
|
||||
for (int index = 0; index < units.size(); index++) {
|
||||
var unit = units.get(index);
|
||||
var shares = split(unitShares.get(index), unit.recipients().size(), random);
|
||||
for (int recipient = 0; recipient < unit.recipients().size(); recipient++) {
|
||||
result.put(unit.recipients().get(recipient), shares.get(recipient));
|
||||
}
|
||||
}
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
|
||||
private static List<BigInteger> split(BigInteger pool, int count, RandomGenerator random) {
|
||||
var divisor = BigInteger.valueOf(count);
|
||||
var quotient = pool.divide(divisor);
|
||||
int remainder = pool.remainder(divisor).intValueExact();
|
||||
var shares = new ArrayList<BigInteger>(java.util.Collections.nCopies(count, quotient));
|
||||
var order = new ArrayList<Integer>();
|
||||
for (int index = 0; index < count; index++) order.add(index);
|
||||
for (int index = 0; index < remainder; index++) {
|
||||
int selected = index + random.nextInt(count - index);
|
||||
int recipient = order.get(selected);
|
||||
order.set(selected, order.get(index));
|
||||
order.set(index, recipient);
|
||||
shares.set(recipient, shares.get(recipient).add(BigInteger.ONE));
|
||||
}
|
||||
return shares;
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
|
||||
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
|
||||
private games.dmg.fantasystick.persistence.PartySettings partySettings;
|
||||
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
|
||||
@@ -301,6 +302,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
|
||||
public void 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 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) {
|
||||
@@ -508,6 +510,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)) { switchDelayConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)) { roleplayConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)) { partyConfiguration(sender,args); return; }
|
||||
if (coinSettings != null && games.dmg.fantasystick.persistence.CoinSettings.keys().contains(args[1])) { coinConfiguration(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
|
||||
@@ -1100,6 +1103,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void sheetResourcePackConfiguration(CommandSender sender,String[] args){if(sheetResourcePackSettings==null){configurationUsage(sender);return;}String key=args[1];if(args.length==2){sender.sendMessage(key+"="+sheetResourcePackSettings.value(key));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SheetResourcePackSettings.Update> operation;try{operation=sheetResourcePackSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Sheet resource-pack value is invalid; use an absolute HTTPS URL or 40 hexadecimal SHA-1 characters.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-sheet-resource-pack",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=sheetResourcePackSettings.publish(saved);if(applied)sheetResourcePack.deliverAll(server.getOnlinePlayers());}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Sheet resource-pack configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+sheetResourcePackSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+sheetResourcePackSettings.value(key)+"; optional delivery was requested for online players.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
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 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);}});}
|
||||
@@ -1154,6 +1159,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
.filter(org.bukkit.entity.Player::isOnline).map(org.bukkit.entity.Player::getName).toList(), args[1]);
|
||||
if (args.length == 2 && args[0].equals("config")) {
|
||||
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.RoleplaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.PartySettings.KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY);
|
||||
keys.addAll(games.dmg.fantasystick.persistence.CoinSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys());
|
||||
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY);
|
||||
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY);
|
||||
@@ -1183,6 +1189,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)&&switchDelaySettings!=null){String current=switchDelaySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT),args[2]);}
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)&&roleplaySettings!=null)return matching(List.of("false","true"),args[2]);
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)&&partySettings!=null){String current=partySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.PartySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.PartySettings.DEFAULT),args[2]);}
|
||||
if(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(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])) {
|
||||
|
||||
@@ -42,6 +42,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private final EquipmentSessionStore receipts;
|
||||
private Supplier<Duration> switchDelay;
|
||||
private java.util.function.BooleanSupplier roleplayDisabled = () -> false;
|
||||
private java.util.function.Predicate<UUID> pvpDefeat = ignored -> false;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Notice> notices;
|
||||
@@ -164,6 +165,11 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
roleplayDisabled = Objects.requireNonNull(policy);
|
||||
}
|
||||
|
||||
public synchronized void attachPvpDefeat(java.util.function.Predicate<UUID> predicate) {
|
||||
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
|
||||
pvpDefeat = Objects.requireNonNull(predicate);
|
||||
}
|
||||
|
||||
/** Install before exposing entry so registered definitions drive delivered control metadata. */
|
||||
public synchronized void attachAbilityRegistry(games.dmg.fantasystick.ability.AbilityRegistry registry) {
|
||||
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
|
||||
@@ -598,7 +604,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
|
||||
// Submit first: any subsequent native getter/setter/notice can reenter disable, which
|
||||
// closes CharacterStorage. No dependent character work is submitted after this point.
|
||||
persist(() -> characters.recordDeath(attempt.character), (dead, failure) -> {
|
||||
boolean loseCoins = pvpDefeat.test(owner);
|
||||
persist(() -> characters.recordDeath(attempt.character, loseCoins), (dead, failure) -> {
|
||||
if (failure != null) { diagnostic(attempt, failure); conversionUncertain(attempt, "Death save failed or is uncertain"); return; }
|
||||
if (dead.isEmpty()) { conversionUncertain(attempt, "Character was missing or already dead; no new Tail authorized"); return; }
|
||||
attempt.deathResolved = true;
|
||||
|
||||
@@ -24,6 +24,14 @@ starting-points: 40
|
||||
xp-per-level: 100
|
||||
maximum-level: 30
|
||||
|
||||
# Character-bound coins and combat rewards. Random ranges are inclusive.
|
||||
starting-coins-min: 1
|
||||
starting-coins-max: 6
|
||||
hostile-coin-drop-min: 1
|
||||
hostile-coin-drop-max: 5
|
||||
boss-coin-drop-min: 25
|
||||
boss-coin-drop-max: 50
|
||||
|
||||
# Character-bound combat rewards. Restricted hostile/neutral/boss history uses mob death positions.
|
||||
passive-xp: 1
|
||||
ordinary-xp: 10
|
||||
|
||||
@@ -172,6 +172,8 @@ class AbilityEngineNativeTest {
|
||||
assertTrue(cooled.cooldowns().get("test_focus").isAfter(java.time.Instant.now()));
|
||||
var cooling = a.core.inventory.getItem(2);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, cooling.getType());
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
cooling.getItemMeta().getItemModel());
|
||||
assertEquals(net.kyori.adventure.text.Component.text("Test Focus — Cooling Down"), cooling.getItemMeta().displayName());
|
||||
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry).ability(cooling).orElseThrow());
|
||||
|
||||
@@ -189,6 +191,8 @@ class AbilityEngineNativeTest {
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, a.core.inventory.getItem(2).getType());
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
a.core.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry)
|
||||
.ability(a.core.inventory.getItem(2)).orElseThrow());
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
|
||||
|
||||
@@ -66,12 +66,13 @@ class CharacterBookNativeTest {
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.STR, strength);
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
stats, 30, java.math.BigInteger.valueOf(2900), java.math.BigInteger.valueOf(9), old.health(),
|
||||
java.util.Set.of("revive", "unavailable_saved_id"), old.loadout(), java.util.Map.of(), false, null, null);
|
||||
java.util.Set.of("revive", "unavailable_saved_id"), old.loadout(), old.passives(), java.util.Map.of(),
|
||||
old.restrictedKills(), false, null, null, java.math.BigInteger.valueOf(123), true, old.rewardReceipts());
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
|
||||
click(f, a); drainReads(f); runQueued(f);
|
||||
assertEquals(1, a.books.size());
|
||||
String rendered = text(a.books.getFirst()).replace("\n", "");
|
||||
for (String expected : java.util.List.of("Level: 30", "XP: MAX", "Unspent points: 9", "STR: " + strength,
|
||||
for (String expected : java.util.List.of("Level: 30", "XP: MAX", "Unspent points: 9", "Coins: 123", "STR: " + strength,
|
||||
"Revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
|
||||
assertEquals(saved, characters(f).find(old.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
}
|
||||
|
||||
@@ -20,6 +20,20 @@ class CombatExperienceNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void coinClassificationSeparatesNeutralAndWitherRulesFromExistingXpPools() {
|
||||
assertEquals(games.dmg.fantasystick.progression.CoinRules.Target.PASSIVE,
|
||||
games.dmg.fantasystick.party.CombatExperience.coinTarget(type(EntityType.COW)));
|
||||
assertEquals(games.dmg.fantasystick.progression.CoinRules.Target.NEUTRAL,
|
||||
games.dmg.fantasystick.party.CombatExperience.coinTarget(type(EntityType.BEE)));
|
||||
assertEquals(games.dmg.fantasystick.progression.CoinRules.Target.HOSTILE,
|
||||
games.dmg.fantasystick.party.CombatExperience.coinTarget(type(EntityType.ELDER_GUARDIAN)));
|
||||
var wither = type(EntityType.WITHER);
|
||||
assertEquals(games.dmg.fantasystick.progression.CoinRules.Target.BOSS,
|
||||
games.dmg.fantasystick.party.CombatExperience.coinTarget(wither));
|
||||
assertEquals(games.dmg.fantasystick.progression.CombatRewardRules.Target.ORDINARY,
|
||||
games.dmg.fantasystick.party.CombatExperience.classify(wither));
|
||||
}
|
||||
|
||||
@Test void packagedBasicAttackToBossDeathPersistsLevelAndUpdatesBookFeedbackExactlyOnce() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -27,6 +41,7 @@ class CombatExperienceNativeTest {
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var world = mock(org.bukkit.World.class); var worldId = UUID.randomUUID(); when(world.getUID()).thenReturn(worldId);
|
||||
var startingCoins = record(f, a).coins();
|
||||
var target = mob(EntityType.WARDEN, new Location(world, 10, 65, -4));
|
||||
doAnswer(call -> {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
@@ -53,6 +68,9 @@ class CombatExperienceNativeTest {
|
||||
assertEquals(java.math.BigInteger.valueOf(100), saved.experience());
|
||||
assertEquals(2, saved.level());
|
||||
assertEquals(java.math.BigInteger.valueOf(41), saved.unspentPoints());
|
||||
var bossCoins = saved.coins().subtract(startingCoins);
|
||||
assertTrue(bossCoins.compareTo(java.math.BigInteger.valueOf(25)) >= 0
|
||||
&& bossCoins.compareTo(java.math.BigInteger.valueOf(50)) <= 0);
|
||||
assertEquals(17, death.getDroppedExp(), "Fantasy rewards never replace native Minecraft XP");
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("100") && message.contains("XP")));
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Level 2")));
|
||||
@@ -82,10 +100,14 @@ class CombatExperienceNativeTest {
|
||||
f.activate(a); f.activate(b); f.activate(c);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
|
||||
var initialCoins = record(f, a).coins();
|
||||
var first = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
|
||||
hit(f, a.player, first, false);
|
||||
die(f, first); settle(f);
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, a).experience());
|
||||
var hostileCoins = record(f, a).coins().subtract(initialCoins);
|
||||
assertTrue(hostileCoins.compareTo(java.math.BigInteger.ONE) >= 0
|
||||
&& hostileCoins.compareTo(java.math.BigInteger.valueOf(5)) <= 0);
|
||||
assertEquals(java.math.BigInteger.ZERO, record(f, b).experience());
|
||||
|
||||
var near = mob(EntityType.BEE, new Location(world, 2, 64, 0)); // Neutral, regardless of aggression.
|
||||
@@ -95,32 +117,68 @@ class CombatExperienceNativeTest {
|
||||
var ordinary = PartyCombatNativeTest.entity(org.bukkit.entity.Arrow.class);
|
||||
hit(f, ordinary, near, false); // Ordinary sources never enter the fantasy denominator.
|
||||
PartyCombatNativeTest.exit(f, b); // The arrow's originating character still owns its accepted contribution.
|
||||
var beforeNeutralCoins = record(f, a).coins().add(record(f, b).coins());
|
||||
die(f, near); settle(f);
|
||||
assertEquals(beforeNeutralCoins, record(f, a).coins().add(record(f, b).coins()),
|
||||
"Neutral mobs award XP but no coins regardless of aggression");
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Blocked share is lost rather than redistributed");
|
||||
assertEquals(java.math.BigInteger.valueOf(5), record(f, b).experience(), "First restricted kill is eligible for this character");
|
||||
assertEquals(java.math.BigInteger.ZERO, record(f, b).experience(),
|
||||
"An exited direct attacker is no longer actively played at defeat and receives no unit");
|
||||
|
||||
var passive = mob(EntityType.COW, new Location(world, 2, 64, 0));
|
||||
hit(f, a.player, passive, false);
|
||||
hit(f, c.player, passive, false);
|
||||
// b is now ordinary; its direct damage cannot enter the fantasy denominator.
|
||||
hit(f, b.player, passive, false);
|
||||
var beforePassiveCoins = record(f, a).coins().add(record(f, c).coins());
|
||||
die(f, passive); settle(f);
|
||||
assertEquals(java.math.BigInteger.valueOf(11), record(f, a).experience());
|
||||
assertEquals(java.math.BigInteger.valueOf(5), record(f, b).experience());
|
||||
assertEquals(java.math.BigInteger.ONE, record(f, c).experience(),
|
||||
"A one-XP passive pool rounds up to one for every fantasy contributor");
|
||||
assertEquals(beforePassiveCoins, record(f, a).coins().add(record(f, c).coins()));
|
||||
var passiveA = record(f, a).experience();
|
||||
var passiveC = record(f, c).experience();
|
||||
assertEquals(java.math.BigInteger.valueOf(11), passiveA.add(passiveC),
|
||||
"The one-XP passive pool is conserved across the two solo units");
|
||||
assertEquals(1, java.util.stream.Stream.of(passiveA.subtract(java.math.BigInteger.TEN), passiveC)
|
||||
.filter(java.math.BigInteger.ONE::equals).count(), "Exactly one random unit receives the remainder");
|
||||
assertEquals(java.math.BigInteger.ZERO, record(f, b).experience());
|
||||
|
||||
var exact = mob(EntityType.ZOMBIE, new Location(world, 5, 64, 0));
|
||||
hit(f, a.player, exact, false);
|
||||
hit(f, a.player, exact, true); // Cancelled repeats do not create another contributor.
|
||||
die(f, exact); settle(f);
|
||||
assertEquals(java.math.BigInteger.valueOf(21), record(f, a).experience(),
|
||||
assertEquals(passiveA.add(java.math.BigInteger.TEN), record(f, a).experience(),
|
||||
"Passive kill did not reset history; exact three-block distance from blocked kill is eligible");
|
||||
assertEquals(new java.math.BigDecimal("5.0"), record(f, a).restrictedKills().values().iterator().next().x());
|
||||
|
||||
var repeatedPassive = mob(EntityType.SHEEP, new Location(world, 2, 64, 0));
|
||||
hit(f, a.player, repeatedPassive, false); die(f, repeatedPassive); settle(f);
|
||||
assertEquals(java.math.BigInteger.valueOf(22), record(f, a).experience(), "Same-location passive kills remain eligible");
|
||||
assertEquals(passiveA.add(java.math.BigInteger.valueOf(11)), record(f, a).experience(), "Same-location passive kills remain eligible");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void representedPartyIsOneUnitAndSharesWithActiveNonAttackingMembers() 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 world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var target = mob(EntityType.ZOMBIE, new Location(world, 10, 64, 0));
|
||||
hit(f, solo.player, target, false); hit(f, a.player, target, false); hit(f, b.player, target, false);
|
||||
die(f, target); settle(f); settle(f); settle(f);
|
||||
assertEquals(java.math.BigInteger.valueOf(5), 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.valueOf(5), partyXp.stream().reduce(java.math.BigInteger.ZERO, java.math.BigInteger::add));
|
||||
assertEquals(java.util.List.of(java.math.BigInteger.ONE, java.math.BigInteger.TWO, java.math.BigInteger.TWO),
|
||||
partyXp.stream().sorted().toList());
|
||||
assertTrue(record(f, c).experience().signum() > 0, "The active non-attacking member shares the represented party unit");
|
||||
var coinDelta = java.util.List.of(record(f, solo).coins(), record(f, a).coins(), record(f, b).coins(), record(f, c).coins());
|
||||
assertTrue(coinDelta.stream().allMatch(value -> value.signum() >= 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +191,7 @@ class CombatExperienceNativeTest {
|
||||
var attacker = mock(org.bukkit.entity.Player.class);
|
||||
when(attacker.getUniqueId()).thenReturn(active.player());
|
||||
when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active));
|
||||
when(sessions.activeCharacter(active.character())).thenReturn(java.util.Optional.of(active));
|
||||
when(sessions.current(active)).thenReturn(true);
|
||||
when(storage.find(original.id())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(java.util.Optional.of(original)));
|
||||
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.CombatAward>();
|
||||
@@ -144,26 +203,26 @@ class CombatExperienceNativeTest {
|
||||
new games.dmg.fantasystick.persistence.CharacterStorage.CombatAward(
|
||||
games.dmg.fantasystick.persistence.CharacterStorage.CombatAwardStatus.APPLIED, java.util.Optional.of(saved), 0));
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
when(storage.awardCombatExperience(eq(original.id()), any(), eq(java.math.BigInteger.TEN), same(progression), any(), any()))
|
||||
.thenAnswer(ignored -> writes.getAndIncrement() == 0 ? failed : success);
|
||||
when(storage.awardCombatReward(eq(original.id()), any(), any(), eq(java.math.BigInteger.TEN), 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,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), sessions,
|
||||
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC), Runnable::run,
|
||||
ignored -> null, failures::add);
|
||||
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 damageSource = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(damageSource.getDirectEntity()).thenReturn(attacker); when(damageSource.getCausingEntity()).thenReturn(attacker);
|
||||
service.damage(new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, damageSource, 1));
|
||||
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
|
||||
verify(storage, times(1)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
|
||||
verify(storage, times(1)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
|
||||
assertEquals(1, failures.size());
|
||||
service.retryPending();
|
||||
verify(storage, times(2)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
|
||||
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));
|
||||
service.retryPending();
|
||||
verify(storage, times(2)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
|
||||
verify(storage, times(2)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
|
||||
service.close();
|
||||
}
|
||||
|
||||
@@ -172,6 +231,19 @@ class CombatExperienceNativeTest {
|
||||
"xp-per-level: 10\nordinary-xp: 25\nrestricted-kill-spacing-blocks: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(java.util.List.of("starting-coins-max", "starting-coins-min"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "starting-c"}));
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "hostile-coin"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "starting-coins-min", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied"));
|
||||
sender.allowed = true;
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "starting-coins-min"});
|
||||
assertTrue(sender.messages.getLast().contains("=1"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "starting-coins-min", "0"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertTrue(sender.messages.getLast().contains("=0"));
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
for (int index = 0; index < 2; index++) {
|
||||
@@ -212,14 +284,18 @@ class CombatExperienceNativeTest {
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(second);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b);
|
||||
var attackerCoins = record(f, a).coins();
|
||||
var victimCoins = record(f, b).coins();
|
||||
hit(f, a.player, b.player, false);
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
var defeat = new org.bukkit.event.entity.PlayerDeathEvent(b.player, source, new ArrayList<>(), 0,
|
||||
net.kyori.adventure.text.Component.text("defeat"), true);
|
||||
f.dispatcher.accept(defeat); settle(f); settle(f);
|
||||
f.dispatcher.accept(defeat); settle(f); settle(f); settle(f);
|
||||
assertTrue(defeat.isCancelled());
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Level-one fantasy defeat supplies a ten-XP pool");
|
||||
assertEquals(attackerCoins.add(victimCoins), record(f, a).coins());
|
||||
assertTrue(record(f, b).dead());
|
||||
assertEquals(java.math.BigInteger.ZERO, record(f, b).coins(), "PvP defeat binds and zeroes the full saved balance");
|
||||
f.dispatcher.accept(defeat); settle(f);
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Repeated intercepted callback cannot award twice");
|
||||
}
|
||||
@@ -250,6 +326,10 @@ class CombatExperienceNativeTest {
|
||||
return storage.find(actor.core.record.id()).get().orElseThrow();
|
||||
}
|
||||
|
||||
private static LivingEntity type(EntityType type) {
|
||||
var entity = mock(LivingEntity.class); when(entity.getType()).thenReturn(type); return entity;
|
||||
}
|
||||
|
||||
private static LivingEntity mob(EntityType type, Location location) {
|
||||
var entity = mock(LivingEntity.class);
|
||||
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
|
||||
@@ -1078,13 +1078,22 @@ class EquipmentSessionsNativeTest {
|
||||
assertTrue(f.sessions.acknowledgeProgress(active, learned.revision(), cooled));
|
||||
var pane = actor.inventory.getItem(2).clone();
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, pane.getType());
|
||||
assertEquals(SessionControls.COOLDOWN_MODEL, pane.getItemMeta().getItemModel(),
|
||||
"Only the acknowledged persisted cooldown control selects the cosmetic model");
|
||||
assertFalse(f.sessions.acknowledgeProgress(active, current.revision(), learned),
|
||||
"A stale pre-cooldown acknowledgment cannot replace the current presentation");
|
||||
assertEquals(SessionControls.COOLDOWN_MODEL,
|
||||
actor.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
|
||||
f.sessions.exit(actor.player); f.state(actor); f.activate(actor);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, actor.inventory.getItem(2).getType(),
|
||||
"Resume reconstructs the future persisted deadline");
|
||||
assertEquals(SessionControls.COOLDOWN_MODEL, actor.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
clock.advance(Duration.ofSeconds(30));
|
||||
f.sessions.refreshCooldownControls(1);
|
||||
assertEquals(Material.SNOWBALL, actor.inventory.getItem(2).getType());
|
||||
assertNull(actor.inventory.getItem(2).getItemMeta().getItemModel(),
|
||||
"Exact-deadline restoration replaces the whole control without stale cosmetic metadata");
|
||||
assertNotEquals(pane, actor.inventory.getItem(2), "The retired pane is no longer installed");
|
||||
assertEquals(deadline, f.sessions.currentRecord(f.sessions.active(actor.id).orElseThrow()).orElseThrow()
|
||||
.cooldowns().get("fireball"), "Visual restoration does not rewrite the deadline");
|
||||
|
||||
@@ -60,8 +60,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
|
||||
assertNotNull(fixture.packetListener);
|
||||
assertEquals(10, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, Speed I and session particles");
|
||||
assertEquals(11, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects and session particles");
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
|
||||
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
|
||||
|
||||
@@ -88,11 +88,15 @@ class FireballNativeTest {
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball");
|
||||
assertTrue(deadline.isAfter(java.time.Instant.now()));
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
a.core.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
a.core.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
assertEquals("fireball", new games.dmg.fantasystick.items.SessionControls(
|
||||
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
|
||||
.ability(a.core.inventory.getItem(2)).orElseThrow());
|
||||
@@ -145,6 +149,8 @@ class FireballNativeTest {
|
||||
assertTrue(new games.dmg.fantasystick.items.SessionControls(
|
||||
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
|
||||
.ability(a.core.inventory.getItem(2)).isEmpty());
|
||||
assertNull(a.core.inventory.getItem(2).getItemMeta().getItemModel(),
|
||||
"Unlearning replaces the cooling control and leaves no stale invisible model");
|
||||
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 WIS]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
@@ -154,6 +160,9 @@ class FireballNativeTest {
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
a.core.inventory.getItem(2).getItemMeta().getItemModel(),
|
||||
"Relearning reconstructs the retained future cooldown model");
|
||||
cast(f, a); assertEquals(1, launches.get());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
|
||||
@@ -164,6 +173,8 @@ class FireballNativeTest {
|
||||
b.core.inventory.getInventory().setSelectedSlot(2);
|
||||
assertEquals(org.bukkit.Material.GRAY_STAINED_GLASS_PANE, b.core.inventory.getItem(2).getType(),
|
||||
"The transferred character reconstructs its retained cooldown pane");
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
b.core.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
assertEquals("fireball", new games.dmg.fantasystick.items.SessionControls(
|
||||
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
|
||||
.ability(b.core.inventory.getItem(2)).orElseThrow());
|
||||
|
||||
@@ -62,8 +62,8 @@ class HexNativeTest {
|
||||
var holder=new FantasyStickLifecycleNativeTest.Actor(data);holder.core.inventory.setItemInOffHand(sourceSheet);f.players.put(holder.core.id,holder.player);f.activate(holder);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy, allySource, new java.util.ArrayList<>(), 0));
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
|
||||
assertEquals(BigInteger.valueOf(5), storage.find(a.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage.find(b.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.ZERO, storage.find(a.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.TEN, storage.find(b.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ class HexNativeTest {
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));
|
||||
for(var caster:List.of(a,c)){doReturn(new Location(a.world,0,64,0)).when(caster.player).getLocation();doReturn(true).when(caster.player).hasLineOfSight(enemy);doReturn(enemy).when(caster.player).getTargetEntity(64);use(f,caster);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
|
||||
var allySource=DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new EntityDamageByEntityEvent(b.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));var enemySource=DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new EntityDamageByEntityEvent(enemy,b.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,10);f.dispatcher.accept(hit);assertEquals(8,hit.getDamage(),1.0e-9);f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.ZERO,storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(c.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var ax=storage.find(a.core.record.id()).get().orElseThrow().experience();var cx=storage.find(c.core.record.id()).get().orElseThrow().experience();var bx=storage.find(b.core.record.id()).get().orElseThrow().experience();assertEquals(BigInteger.TEN,ax.add(cx).add(bx));assertTrue(List.of(ax,cx,bx).stream().allMatch(value->value.equals(BigInteger.valueOf(3))||value.equals(BigInteger.valueOf(4))));
|
||||
}}
|
||||
|
||||
@Test void unusedProtectionCannotManufactureSupportShare() throws Exception {
|
||||
@@ -104,8 +104,8 @@ class HexNativeTest {
|
||||
var allySource=DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(b.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.ZERO,storage.find(a.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.TEN,storage.find(b.core.record.id()).get().orElseThrow().experience());
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.valueOf(5),storage.find(a.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()thr
|
||||
@Test void aimedPartyAllyActualAbsorptionCreatesPersistedSupportShare()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));
|
||||
doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3));absorption.set(2.0);PartyBookNativeTest.drain(f);var sourceSheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var holder=new FantasyStickLifecycleNativeTest.Actor(data);holder.core.inventory.setItemInOffHand(sourceSheet);f.players.put(holder.core.id,holder.player);f.activate(holder);
|
||||
var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.valueOf(5),storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());}}
|
||||
var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.ZERO,storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.TEN,storage.find(b.core.record.id()).get().orElseThrow().experience());}}
|
||||
@Test void normalSelfOrdinaryOutOfRangeAndObstructedTargetsRefuseBeforeCooldown()throws Exception{for(int route=0;route<4;route++){var dir=data.resolve("refuse-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;if(route==0)target=a.player;else if(route==1){target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}else{var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);f.activate(b);}doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,route==2?13:8,64,0)).when(target).getLocation();doReturn(route!=3).when(a.player).hasLineOfSight(target);doReturn(target).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("inspire"));}}}
|
||||
@Test void nonPartyTargetIsAllowedAndStrongestRemainingCopiesPreserveUnrelatedAbsorption()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var c=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);withInspire(data,c,true);f.players.put(a.core.id,a.player);f.players.put(c.core.id,c.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(c);f.activate(b);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());for(var caster:List.of(a,c)){doReturn(false).when(caster.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(caster.player).getLocation();doReturn(true).when(caster.player).hasLineOfSight(b.player);doReturn(b.player).when(caster.player).getTargetEntity(64);}doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","6"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();use(f,c);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(9,absorption.get(),1e-9);
|
||||
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));absorption.set(6.0);PartyBookNativeTest.drain(f);f.delayed.getLast().run();assertEquals(4,absorption.get(),1e-9);f.delayed.getFirst().run();assertEquals(2,absorption.get(),1e-9);}}
|
||||
@Test void cancelledOrSelfOnlyProtectionCannotManufactureSupportShare()throws Exception{for(int route=0;route<2;route++){var dir=data.resolve("no-support-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);var b=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));var target=route==0?b.player:a.player;var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(x->absorption.get()).when(target).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(target).setAbsorptionAmount(anyDouble());doReturn(route==1).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,target,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3);if(route==0)hit.setCancelled(true);f.dispatcher.accept(hit);if(route==1){absorption.set(2.0);PartyBookNativeTest.drain(f);}var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.ZERO,storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.TEN,storage.find(b.core.record.id()).get().orElseThrow().experience());}}}
|
||||
@Test void cancelledOrSelfOnlyProtectionCannotManufactureSupportShare()throws Exception{for(int route=0;route<2;route++){var dir=data.resolve("no-support-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);var b=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));var target=route==0?b.player:a.player;var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(x->absorption.get()).when(target).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(target).setAbsorptionAmount(anyDouble());doReturn(route==1).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,target,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3);if(route==0)hit.setCancelled(true);f.dispatcher.accept(hit);if(route==1){absorption.set(2.0);PartyBookNativeTest.drain(f);}var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.valueOf(5),storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());}}}
|
||||
@Test void exactFormulaSupportsFiniteUncappedCharisma(){assertEquals(BigInteger.valueOf(5),games.dmg.fantasystick.ability.Inspire.amount(BigInteger.valueOf(12)));assertEquals(BigInteger.valueOf(3),games.dmg.fantasystick.ability.Inspire.amount(BigInteger.valueOf(9)));var huge=BigInteger.TEN.pow(500);assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)),games.dmg.fantasystick.ability.Inspire.amount(huge));}
|
||||
@Test void failedCooldownPersistencePublishesNoTemporaryHealth(){var storage=mock(CharacterStorage.class);var sessions=mock(games.dmg.fantasystick.session.EquipmentSessions.class);var parties=new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(),ignored->true);var temporary=new games.dmg.fantasystick.ability.TemporaryHealth(sessions,parties,Runnable::run,ignored->{});var inspire=new games.dmg.fantasystick.ability.Inspire(games.dmg.fantasystick.ability.Inspire.Settings.defaults(),temporary);var registry=new games.dmg.fantasystick.ability.AbilityRegistry();registry.register(inspire.definition());var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var stats=new EnumMap<>(base.stats());stats.put(Stat.CHR,BigInteger.valueOf(12));stats.put(Stat.WIS,BigInteger.TEN);var record=new CharacterRecord(base.id(),base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),base.health(),Set.of("inspire"),List.of("inspire","","","","","",""),base.cooldowns(),base.restrictedKills(),false,null,null);var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),java.util.UUID.randomUUID(),record.id());var player=mock(org.bukkit.entity.Player.class);when(player.getUniqueId()).thenReturn(active.player());when(player.isSneaking()).thenReturn(true);when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active));when(sessions.current(active)).thenReturn(true);when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));var failed=new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();failed.completeExceptionally(new java.io.IOException("simulated"));when(storage.commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("inspire"),any())).thenReturn(failed);var engine=new games.dmg.fantasystick.ability.AbilityEngine(registry,storage,sessions,parties,java.time.Clock.systemUTC(),Runnable::run,(delay,action)->()->{},ignored->{},ignored->{});engine.accept(new games.dmg.fantasystick.session.ControlInput(player,active,2,"inspire",games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,games.dmg.fantasystick.session.ControlInput.Gesture.USE,player,0,0,()->true));verify(storage).commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("inspire"),any());verify(player,never()).setAbsorptionAmount(anyDouble());engine.close();inspire.close();temporary.close();parties.close();}
|
||||
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedTemporaryHealth()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 60\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var deadline=storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire");a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);var unlearn=button(a,"[Unlearn]","inspire");PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(2,absorption.get(),1e-9);var refunded=storage.find(a.core.record.id()).get().orElseThrow();assertEquals(BigInteger.valueOf(40),refunded.unspentPoints());assertEquals(deadline,refunded.cooldowns().get("inspire"));learnAndAssign(f,a);use(f,a);assertTrue(a.messages.stream().anyMatch(m->m.contains("ready in")));var sheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var b=new FantasyStickLifecycleNativeTest.Actor(data);b.core.inventory.setItemInOffHand(sheet);f.players.put(b.core.id,b.player);f.activate(b);doReturn(true).when(b.player).isSneaking();use(f,b);assertTrue(b.messages.stream().anyMatch(m->m.contains("ready in")));assertEquals(deadline,storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.passive.LowLightVision;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class LowLightVisionNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void connectedLearnExitTransferUnlearnFailureAndDefeatOwnOnlyCharacterNightVision() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); withWis(data, a, List.of("", "", ""));
|
||||
var aEffects = effects(a.player); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var vision = (LowLightVision) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "lowLightVision");
|
||||
PartyBookNativeTest.open(f, a);
|
||||
assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("Low-LightVisionWIS12[Learn]"));
|
||||
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "low_light_vision")); settle(f); vision.sweep();
|
||||
var learned = record(f, a); assertEquals(List.of("low_light_vision", "", ""), learned.passives());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
|
||||
assertEquals(0, aEffects.get(PotionEffectType.NIGHT_VISION).getAmplifier());
|
||||
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone(); a.core.inventory.setItemInOffHand(null);
|
||||
f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a); vision.sweep();
|
||||
assertFalse(aEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); var bEffects = effects(b.player);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b); vision.sweep();
|
||||
assertTrue(bEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
PartyBookNativeTest.open(f, b); PartyBookNativeTest.click(f, b, action(b, "[Unlearn]", "low_light_vision")); settle(f); vision.sweep();
|
||||
var unlearned = storage(f).find(learned.id()).get().orElseThrow(); assertFalse(unlearned.passives().contains("low_light_vision"));
|
||||
assertEquals(learned.unspentPoints().add(BigInteger.ONE), unlearned.unspentPoints());
|
||||
assertFalse(bEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
|
||||
PartyBookNativeTest.open(f, b); Runnable failed;
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters").resolve(".records.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid()); PartyBookNativeTest.click(f, b, action(b, "[Learn]", "low_light_vision"));
|
||||
failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed);
|
||||
}
|
||||
failed.run(); vision.sweep(); assertFalse(bEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
assertFalse(storage(f).find(learned.id()).get().orElseThrow().passives().contains("low_light_vision"));
|
||||
|
||||
b.core.inventory.setItemInOffHand(null); f.changed(b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
storage(f).find(learned.id()).get(); f.receipts().find(b.core.id).toCompletableFuture().get();
|
||||
withWis(data, a, List.of("low_light_vision", "", ""));
|
||||
var c = new FantasyStickLifecycleNativeTest.Actor(data); var cEffects = effects(c.player);
|
||||
c.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(
|
||||
new YamlCharacterRepository(data).load(a.core.record.id()).orElseThrow()));
|
||||
f.players.put(c.core.id, c.player); f.activate(c); vision.sweep(); assertTrue(cEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
c.player.setHealth(1);
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(c.player, mock(org.bukkit.damage.DamageSource.class),
|
||||
new java.util.ArrayList<>(), 0, net.kyori.adventure.text.Component.text("defeat"), true);
|
||||
f.dispatcher.accept(death); settle(f); vision.sweep(); assertFalse(cEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void milkAndUnrelatedNightVisionReconcileWithoutTouchingBlindnessOrDarkness() {
|
||||
var sessions = mock(EquipmentSessions.class); var player = mock(Player.class); var effects = effects(player);
|
||||
UUID id = UUID.randomUUID(); var active = new EquipmentSessions.Active(id, UUID.randomUUID(), UUID.randomUUID());
|
||||
var record = mock(CharacterRecord.class); when(record.passives()).thenReturn(List.of("low_light_vision", "", ""));
|
||||
when(player.getUniqueId()).thenReturn(id); when(sessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(active, record)));
|
||||
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
|
||||
when(sessions.currentPlayer(active)).thenReturn(java.util.Optional.of(player));
|
||||
var blindness = new PotionEffect(PotionEffectType.BLINDNESS, 100, 0);
|
||||
var darkness = new PotionEffect(PotionEffectType.DARKNESS, 100, 0);
|
||||
effects.put(PotionEffectType.BLINDNESS, blindness); effects.put(PotionEffectType.DARKNESS, darkness);
|
||||
var unrelated = new PotionEffect(PotionEffectType.NIGHT_VISION, 40, 0); effects.put(PotionEffectType.NIGHT_VISION, unrelated);
|
||||
var vision = new LowLightVision(sessions); assertEquals(Map.of(Stat.WIS, BigInteger.valueOf(12)), vision.definition().requirements());
|
||||
vision.sweep(); assertSame(unrelated, effects.get(PotionEffectType.NIGHT_VISION));
|
||||
effects.remove(PotionEffectType.NIGHT_VISION); vision.sweep(); assertTrue(effects.get(PotionEffectType.NIGHT_VISION).isInfinite());
|
||||
assertSame(blindness, effects.get(PotionEffectType.BLINDNESS)); assertSame(darkness, effects.get(PotionEffectType.DARKNESS));
|
||||
effects.remove(PotionEffectType.NIGHT_VISION); vision.sweep();
|
||||
verify(player, times(2)).addPotionEffect(argThat(effect -> effect.getType().equals(PotionEffectType.NIGHT_VISION)), eq(true));
|
||||
when(record.passives()).thenReturn(List.of("", "", "")); vision.sweep();
|
||||
assertFalse(effects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
assertSame(blindness, effects.get(PotionEffectType.BLINDNESS)); assertSame(darkness, effects.get(PotionEffectType.DARKNESS));
|
||||
vision.close();
|
||||
}
|
||||
|
||||
private static Map<PotionEffectType, PotionEffect> effects(Player player) {
|
||||
var result = new HashMap<PotionEffectType, PotionEffect>();
|
||||
doAnswer(call -> result.get(call.getArgument(0))).when(player).getPotionEffect(any());
|
||||
doAnswer(call -> { PotionEffect effect = call.getArgument(0); result.put(effect.getType(), effect); return true; })
|
||||
.when(player).addPotionEffect(any(), eq(true));
|
||||
doAnswer(call -> { result.remove(call.getArgument(0)); return null; }).when(player).removePotionEffect(any()); return result;
|
||||
}
|
||||
private static void withWis(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> passives) throws Exception {
|
||||
var repository = new YamlCharacterRepository(data); var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new EnumMap<>(old.stats()); stats.put(Stat.WIS, BigInteger.valueOf(12));
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(),
|
||||
old.unspentPoints(), old.health(), old.learned(), old.loadout(), passives, old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
return storage(f).find(actor.core.record.id()).get().orElseThrow();
|
||||
}
|
||||
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(content);
|
||||
for (var page : content.pages()) { var found = action(page.raw(), label, passive); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + passive);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
net.minecraft.network.chat.Component component, String label, String passive) {
|
||||
if (component.getString().equals(label)
|
||||
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom
|
||||
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
|
||||
&& payload.value().contains(":" + passive)) return custom;
|
||||
for (var child : component.getSiblings()) { var found = action(child, label, passive); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -114,10 +114,10 @@ class MendingLightNativeTest {
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0));
|
||||
drain(fixture, 4);
|
||||
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
assertTrue(currentHolder.messages.stream().anyMatch(message -> message.contains("5") && message.contains("XP")),
|
||||
"Effective healing follows the living source character through transfer");
|
||||
assertEquals(BigInteger.ZERO, storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.TEN, storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
assertFalse(currentHolder.messages.stream().anyMatch(message -> message.contains("XP")),
|
||||
"Support no longer creates a unit and the transient player roster does not follow a transferred sheet");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,9 +96,9 @@ class PurifyNativeTest {
|
||||
fixture.dispatcher.accept(new EntityDamageByEntityEvent(ally.player, enemy,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 3));
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
assertTrue(holder.messages.stream().anyMatch(message -> message.contains("5") && message.contains("XP")));
|
||||
assertEquals(BigInteger.ZERO, storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.TEN, storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
assertFalse(holder.messages.stream().anyMatch(message -> message.contains("XP")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class QuickerRegenerationNativeTest {
|
||||
assertTrue(detail.replaceAll("\\s+", "").contains("out-of-combatrecoverypulse"));
|
||||
|
||||
var beforeLoadout = storage(f).find(a.core.record.id()).get().orElseThrow().loadout();
|
||||
PartyBookNativeTest.click(f, a, custom(nativePages(a), "fantasystick:passive_unlock"));
|
||||
PartyBookNativeTest.click(f, a, custom(List.of(pages.get(jump.page() - 1)), "fantasystick:passive_unlock"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var learned = storage(f).find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(List.of("quicker_regeneration_i", "", ""), learned.passives());
|
||||
|
||||
@@ -48,6 +48,7 @@ class SessionControlsNativeTest {
|
||||
item.getItemMeta().displayName());
|
||||
assertEquals(1, item.getAmount());
|
||||
assertEquals(1, item.getMaxStackSize());
|
||||
assertNull(item.getItemMeta().getItemModel(), "Book, basic attack and No Ability remain unaffected");
|
||||
var expected = new SessionControls.Identity(owner, session, slot);
|
||||
assertEquals(expected, controls.identify(item).orElseThrow());
|
||||
assertTrue(controls.owned(item, owner, session));
|
||||
@@ -82,6 +83,7 @@ class SessionControlsNativeTest {
|
||||
var item = roundTrip(items[abilitySlot + 2]);
|
||||
assertEquals(materials.get(index), item.getType(), ids.get(index));
|
||||
assertEquals(Component.text(names.get(index)), item.getItemMeta().displayName());
|
||||
assertNull(item.getItemMeta().getItemModel(), "Ready ability icons remain unaffected");
|
||||
assertEquals(ids.get(index), item.getItemMeta().getPersistentDataContainer()
|
||||
.get(key("control_ability_id"), PersistentDataType.STRING));
|
||||
assertEquals(1, item.getAmount());
|
||||
@@ -113,9 +115,24 @@ class SessionControlsNativeTest {
|
||||
assertEquals("cooldown", pane.getPersistentDataContainer().get(key("control_kind"), PersistentDataType.STRING));
|
||||
assertEquals(java.util.Optional.of("fireball"), timed.ability(pane));
|
||||
assertEquals(new SessionControls.Identity(owner, session, slot), timed.identify(pane).orElseThrow());
|
||||
assertEquals(SessionControls.COOLDOWN_MODEL, pane.getItemMeta().getItemModel());
|
||||
}
|
||||
assertEquals(Material.ECHO_SHARD, items[4].getType(), "A deadline equal to now is ready");
|
||||
assertNull(items[4].getItemMeta().getItemModel(), "An exact-deadline ready icon retains no stale model");
|
||||
assertEquals(Material.PAPER, items[5].getType(), "Unknown saved IDs remain unavailable, not cooling panes");
|
||||
assertNull(items[5].getItemMeta().getItemModel(), "Unknown controls remain visible and unaffected");
|
||||
|
||||
var genuineWithoutModel = items[2].clone();
|
||||
var genuineMeta = genuineWithoutModel.getItemMeta();
|
||||
genuineMeta.setItemModel(null);
|
||||
genuineWithoutModel.setItemMeta(genuineMeta);
|
||||
assertEquals(java.util.Optional.of("fireball"), timed.ability(genuineWithoutModel),
|
||||
"Cosmetic model metadata is not casting or session authority");
|
||||
var ordinaryPane = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
|
||||
var ordinaryMeta = ordinaryPane.getItemMeta();
|
||||
ordinaryMeta.setItemModel(SessionControls.COOLDOWN_MODEL);
|
||||
ordinaryPane.setItemMeta(ordinaryMeta);
|
||||
assertRejected(ordinaryPane);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -89,12 +89,13 @@ class TailBooksNativeTest {
|
||||
var expiry = old.diedAt().plusSeconds(3600);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision(), old.stats(), old.level(), old.experience(),
|
||||
old.unspentPoints(), old.health(), Set.of("second_wind", "unknown:雪"),
|
||||
List.of("second_wind", "unknown:雪", "second_wind", "", "", "", ""),
|
||||
Map.of("second_wind", expiry, "retired_ability", expiry.plusSeconds(60)), true, old.deathId(), old.diedAt());
|
||||
List.of("second_wind", "unknown:雪", "second_wind", "", "", "", ""), old.passives(),
|
||||
Map.of("second_wind", expiry, "retired_ability", expiry.plusSeconds(60)), old.restrictedKills(),
|
||||
true, old.deathId(), old.diedAt(), BigInteger.valueOf(123), true, old.rewardReceipts());
|
||||
var book = new TailBooks().create(saved);
|
||||
var pages = nativePages(book);
|
||||
String summary = pages.getFirst().replace("\n", "");
|
||||
for (String expected : List.of("Tail of Ari", "Level: 2", "XP: 100", "Unspent points: 30",
|
||||
for (String expected : List.of("Tail of Ari", "Level: 2", "XP: 100", "Unspent points: 30", "Coins: 123",
|
||||
"STR: 0", "DEX: 0", "CON: 10", "INT: 0", "WIS: 0", "CHR: 0")) assertTrue(summary.contains(expected), expected);
|
||||
String wind = pages.stream().map(p -> p.replace("\n", "")).filter(p -> p.contains("Ability: second_wind")).findFirst().orElseThrow();
|
||||
assertTrue(wind.contains("Slots: 1, 3")); assertTrue(wind.contains(expiry.toString()));
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:select",
|
||||
"property": "minecraft:display_context",
|
||||
"cases": [
|
||||
{
|
||||
"when": [
|
||||
"firstperson_lefthand",
|
||||
"firstperson_righthand"
|
||||
],
|
||||
"model": {
|
||||
"type": "minecraft:empty"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fallback": {
|
||||
"type": "minecraft:model",
|
||||
"model": "minecraft:item/gray_stained_glass_pane"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pack": {
|
||||
"description": "Fantasy Stick ${version} sheet presentation",
|
||||
"description": "Fantasy Stick ${version} first-person presentation",
|
||||
"min_format": [88, 0],
|
||||
"max_format": [88, 0]
|
||||
}
|
||||
|
||||
@@ -84,6 +84,11 @@ class DistributionTest {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged Inspire default: " + setting);
|
||||
}
|
||||
assertTrue(defaults.contains("admin-revive-selection-timeout-seconds: 60"));
|
||||
for (String setting : java.util.List.of("starting-coins-min: 1", "starting-coins-max: 6",
|
||||
"hostile-coin-drop-min: 1", "hostile-coin-drop-max: 5",
|
||||
"boss-coin-drop-min: 25", "boss-coin-drop-max: 50")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged coin default: " + setting);
|
||||
}
|
||||
assertTrue(defaults.contains("sheet-resource-pack-url: ''"));
|
||||
assertTrue(defaults.contains("sheet-resource-pack-sha1: ''"));
|
||||
for (String setting : java.util.List.of("out-of-combat-healing-delay-seconds: 30",
|
||||
@@ -106,7 +111,7 @@ class DistributionTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagesVersionedContextSensitiveSheetResourcePackAndMatchingRuntimeMetadata() throws Exception {
|
||||
@Test void packagesVersionedContextSensitiveResourcePackAndMatchingRuntimeMetadata() throws Exception {
|
||||
Path artifact = Path.of(System.getProperty("distribution.jar"));
|
||||
Path pack = Path.of(System.getProperty("resource.pack.zip"));
|
||||
String version = System.getProperty("distribution.version");
|
||||
@@ -129,6 +134,19 @@ class DistributionTest {
|
||||
assertTrue(json.contains("firstperson_righthand"));
|
||||
assertTrue(json.contains("minecraft:empty"));
|
||||
assertTrue(json.contains("minecraft:item/paper"), "Inventory and other contexts retain Paper presentation");
|
||||
var cooldownModel = zip.getEntry("assets/fantasystick/items/cooldown_control.json");
|
||||
assertNotNull(cooldownModel, "The single pack includes the dedicated cooldown-control model");
|
||||
String cooldownJson;
|
||||
try (var input = zip.getInputStream(cooldownModel)) {
|
||||
cooldownJson = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
assertTrue(cooldownJson.contains("minecraft:select"));
|
||||
assertTrue(cooldownJson.contains("minecraft:display_context"));
|
||||
assertTrue(cooldownJson.contains("firstperson_lefthand"));
|
||||
assertTrue(cooldownJson.contains("firstperson_righthand"));
|
||||
assertTrue(cooldownJson.contains("minecraft:empty"));
|
||||
assertTrue(cooldownJson.contains("minecraft:item/gray_stained_glass_pane"),
|
||||
"Hotbar, inventory and all other contexts retain gray-pane presentation");
|
||||
}
|
||||
try (var jar = new JarFile(artifact.toFile())) {
|
||||
var metadata = jar.getJarEntry("sheet-resource-pack.properties"); assertNotNull(metadata);
|
||||
|
||||
@@ -25,6 +25,9 @@ class CharacterFactoryTest {
|
||||
assertEquals(character.name(), replay.create(ProgressionRules.defaults()).name());
|
||||
assertTrue(ids.add(character.id()));
|
||||
assertEquals(BigInteger.valueOf(40), character.unspentPoints());
|
||||
assertTrue(character.coinsInitialized());
|
||||
assertTrue(character.coins().compareTo(BigInteger.ONE) >= 0 && character.coins().compareTo(BigInteger.valueOf(6)) <= 0);
|
||||
assertTrue(character.rewardReceipts().isEmpty());
|
||||
names.add(character.name());
|
||||
}
|
||||
assertTrue(names.size() > 1, "Names must use the supplied randomness, not a constant placeholder");
|
||||
@@ -57,6 +60,9 @@ class CharacterFactoryTest {
|
||||
assertEquals(Set.of(), record.learned());
|
||||
assertEquals(List.of("", "", "", "", "", "", ""), record.loadout());
|
||||
assertEquals(Map.of(), record.cooldowns());
|
||||
assertTrue(record.coinsInitialized());
|
||||
assertTrue(record.coins().compareTo(BigInteger.ONE) >= 0 && record.coins().compareTo(BigInteger.valueOf(6)) <= 0);
|
||||
assertTrue(record.rewardReceipts().isEmpty());
|
||||
assertFalse(record.dead());
|
||||
assertNull(record.deathId());
|
||||
assertNull(record.diedAt());
|
||||
|
||||
@@ -19,6 +19,31 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
class CharacterStorageTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test
|
||||
void firstLoadPersistsExactlyOneConfiguredMigrationRollAndNeverRerollsZero() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var sample = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var legacy = new CharacterRecord(sample.id(), sample.name(), sample.revision(), sample.stats(), sample.level(),
|
||||
sample.experience(), sample.unspentPoints(), sample.health(), sample.learned(), sample.loadout(), sample.passives(),
|
||||
sample.cooldowns(), sample.restrictedKills(), sample.dead(), sample.deathId(), sample.diedAt(),
|
||||
BigInteger.ZERO, false, java.util.Set.of());
|
||||
repository.create(legacy);
|
||||
var rules = new games.dmg.fantasystick.progression.CoinRules(4, 4, 1, 5, 25, 50);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC(), () -> rules, new java.util.Random(7))) {
|
||||
var migrated = storage.find(legacy.id()).join().orElseThrow();
|
||||
assertTrue(migrated.coinsInitialized());
|
||||
assertEquals(BigInteger.valueOf(4), migrated.coins());
|
||||
assertEquals(BigInteger.ONE, migrated.revision());
|
||||
assertEquals(migrated, storage.find(legacy.id()).join().orElseThrow());
|
||||
var spent = new CharacterRecord(migrated.id(), migrated.name(), migrated.revision().add(BigInteger.ONE),
|
||||
migrated.stats(), migrated.level(), migrated.experience(), migrated.unspentPoints(), migrated.health(),
|
||||
migrated.learned(), migrated.loadout(), migrated.passives(), migrated.cooldowns(), migrated.restrictedKills(),
|
||||
migrated.dead(), migrated.deathId(), migrated.diedAt(), BigInteger.ZERO, true, migrated.rewardReceipts());
|
||||
repository.replace(spent, migrated.revision());
|
||||
assertEquals(BigInteger.ZERO, storage.find(legacy.id()).join().orElseThrow().coins());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void performsFileWritesOffTheCallerThreadAndAcknowledgesOnlyAfterCommit() throws Exception {
|
||||
Thread caller = Thread.currentThread();
|
||||
@@ -78,6 +103,59 @@ class CharacterStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void combatRewardAtomicallyPersistsCoinsXpReceiptAndSpacingWithoutDuplicateApplication() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var progression = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.TEN, 30);
|
||||
var rewards = games.dmg.fantasystick.progression.CombatRewardRules.defaults();
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);
|
||||
var live = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
|
||||
base.experience(), base.unspentPoints(), base.health(), base.learned(), base.loadout(), base.passives(),
|
||||
base.cooldowns(), base.restrictedKills(), false, null, null, BigInteger.valueOf(3), true, java.util.Set.of());
|
||||
var rewardId = UUID.randomUUID();
|
||||
var world = UUID.randomUUID();
|
||||
var location = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(world,
|
||||
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
storage.create(live).join();
|
||||
var applied = storage.awardCombatReward(live.id(), live.revision(), rewardId, BigInteger.TEN,
|
||||
BigInteger.valueOf(5), progression, rewards, location).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, applied.status());
|
||||
var saved = applied.record().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(8), saved.coins());
|
||||
assertEquals(BigInteger.TEN, saved.experience());
|
||||
assertTrue(saved.rewardReceipts().contains(rewardId));
|
||||
assertEquals(location, saved.restrictedKills().get(world));
|
||||
|
||||
var duplicate = storage.awardCombatReward(live.id(), live.revision(), rewardId, BigInteger.TEN,
|
||||
BigInteger.valueOf(5), progression, rewards, location).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.DUPLICATE, duplicate.status());
|
||||
assertEquals(saved, duplicate.record().orElseThrow());
|
||||
assertEquals(saved, repository.load(live.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pvpDeathPersistsTheZeroBalanceAndDeathBoundEscrowUntilReviveClearsIt() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var live = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
|
||||
base.experience(), base.unspentPoints(), base.health(), base.learned(), base.loadout(), base.passives(),
|
||||
base.cooldowns(), base.restrictedKills(), false, null, null, BigInteger.valueOf(37), true, java.util.Set.of());
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
storage.create(live).join();
|
||||
var dead = storage.recordDeath(live.id(), true).join().orElseThrow();
|
||||
assertEquals(BigInteger.ZERO, dead.coins());
|
||||
assertEquals(BigInteger.valueOf(37), dead.pvpCoinEscrow());
|
||||
assertEquals(dead, repository.load(live.id()).orElseThrow());
|
||||
|
||||
var revived = storage.restoreDeathRecord(live.id(), dead.deathId()).join().orElseThrow();
|
||||
assertEquals(BigInteger.ZERO, revived.coins());
|
||||
assertEquals(BigInteger.ZERO, revived.pvpCoinEscrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void combatAwardsAtomicallyPersistXpPointsAndPerWorldRestrictedKillHistory() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CoinSettingsTest {
|
||||
@TempDir Path data;
|
||||
private static Map<String,String> defaults(){var values=new LinkedHashMap<String,String>();for(String key:CoinSettings.keys())values.put(key,CoinSettings.defaultValue(key));return values;}
|
||||
@Test void validatesPairsAndPublishesOnlyAfterDurableSaveWhilePreservingUnrelatedKeys()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"unrelated: keep\n");
|
||||
try(var settings=new CoinSettings(data,defaults())){
|
||||
assertEquals(1,settings.rules().startingMin());assertEquals(50,settings.rules().bossMax());
|
||||
assertThrows(IllegalArgumentException.class,()->settings.set(CoinSettings.START_MIN,"7"));
|
||||
assertThrows(IllegalArgumentException.class,()->settings.set(CoinSettings.BOSS_MAX,"2147483648"));
|
||||
var update=settings.set(CoinSettings.START_MAX,"9").toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
assertEquals("6",settings.value(CoinSettings.START_MAX));
|
||||
assertTrue(settings.publish(update));assertEquals("9",settings.value(CoinSettings.START_MAX));
|
||||
assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,38 @@ class YamlCharacterRepositoryTest {
|
||||
original.loadout(), original.cooldowns(), original.dead(), original.deathId(), original.diedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void freshCoinStateRoundTripsThroughTheYamlCodec() {
|
||||
var fresh = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
assertEquals(fresh, CharacterYaml.decode(CharacterYaml.encode(fresh)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void coinStateAndBoundedRewardReceiptsRoundTripWhileLegacyRecordsRemainUninitialized() throws Exception {
|
||||
var original = sample(UUID.randomUUID());
|
||||
var receipt = UUID.randomUUID();
|
||||
var economy = new CharacterRecord(original.id(), original.name(), original.revision(), original.stats(),
|
||||
original.level(), original.experience(), original.unspentPoints(), original.health(), original.learned(),
|
||||
original.loadout(), original.passives(), original.cooldowns(), original.restrictedKills(), original.dead(),
|
||||
original.deathId(), original.diedAt(), BigInteger.TEN.pow(200), true, Set.of(receipt));
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(economy);
|
||||
assertEquals(economy, repository.load(economy.id()).orElseThrow());
|
||||
String yaml = Files.readString(data.resolve("characters").resolve(economy.id() + ".yml"));
|
||||
assertTrue(yaml.contains("coins: '" + BigInteger.TEN.pow(200) + "'"));
|
||||
assertTrue(yaml.contains(receipt.toString()));
|
||||
|
||||
String legacy = CharacterYaml.encode(original)
|
||||
.replace("coins-initialized: true\n", "")
|
||||
.replace("coins: '0'\n", "")
|
||||
.replace("reward-receipts: []\n", "");
|
||||
var decoded = CharacterYaml.decode(legacy);
|
||||
assertFalse(decoded.coinsInitialized());
|
||||
assertEquals(BigInteger.ZERO, decoded.coins());
|
||||
assertTrue(decoded.rewardReceipts().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restrictedKillHistoryRoundTripsPerWorldAndOldRecordsRemainCompatible() throws Exception {
|
||||
var original = sample(UUID.randomUUID());
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.random.RandomGenerator;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CoinRulesTest {
|
||||
@Test void inclusiveConfiguredRollsAreDeterministicBoundedAndClassifiedIndependentlyFromXp() {
|
||||
var rules = new CoinRules(1, 6, 1, 5, 25, 50);
|
||||
var minimum = new FixedRandom(0);
|
||||
var maximum = new FixedRandom(Long.MAX_VALUE);
|
||||
assertEquals(BigInteger.ONE, rules.starting(minimum));
|
||||
assertEquals(BigInteger.valueOf(6), rules.starting(maximum));
|
||||
assertEquals(BigInteger.ONE, rules.mob(CoinRules.Target.HOSTILE, minimum));
|
||||
assertEquals(BigInteger.valueOf(5), rules.mob(CoinRules.Target.HOSTILE, maximum));
|
||||
assertEquals(BigInteger.valueOf(25), rules.mob(CoinRules.Target.BOSS, minimum));
|
||||
assertEquals(BigInteger.valueOf(50), rules.mob(CoinRules.Target.BOSS, maximum));
|
||||
assertEquals(BigInteger.ZERO, rules.mob(CoinRules.Target.PASSIVE, maximum));
|
||||
assertEquals(BigInteger.ZERO, rules.mob(CoinRules.Target.NEUTRAL, maximum));
|
||||
assertThrows(IllegalArgumentException.class, () -> new CoinRules(6, 1, 1, 5, 25, 50));
|
||||
assertThrows(IllegalArgumentException.class, () -> new CoinRules(0, Integer.MAX_VALUE, 0, Integer.MAX_VALUE, 0, Integer.MAX_VALUE + 1L));
|
||||
}
|
||||
|
||||
private static final class FixedRandom implements RandomGenerator {
|
||||
private final long value;
|
||||
private FixedRandom(long value) { this.value = value; }
|
||||
@Override public long nextLong() { return value; }
|
||||
@Override public long nextLong(long bound) { return value == 0 ? 0 : bound - 1; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RewardAllocatorTest {
|
||||
@Test void conservesExactPoolsAcrossSoloAndPartyUnitsWithDistinctRandomRemainders() {
|
||||
var units = List.of(
|
||||
new RewardAllocator.Unit<String>("solo", List.of("solo")),
|
||||
new RewardAllocator.Unit<String>("party", List.of("a", "b", "c")));
|
||||
var allocation = RewardAllocator.allocate(BigInteger.TEN, units, new Random(7));
|
||||
assertEquals(BigInteger.valueOf(5), allocation.get("solo"));
|
||||
assertEquals(BigInteger.valueOf(5), allocation.get("a").add(allocation.get("b")).add(allocation.get("c")));
|
||||
assertEquals(BigInteger.TEN, allocation.values().stream().reduce(BigInteger.ZERO, BigInteger::add));
|
||||
assertEquals(List.of(BigInteger.ONE, BigInteger.TWO, BigInteger.TWO),
|
||||
allocation.entrySet().stream().filter(entry -> !entry.getKey().equals("solo"))
|
||||
.map(Map.Entry::getValue).sorted().toList());
|
||||
|
||||
var one = RewardAllocator.allocate(BigInteger.ONE,
|
||||
List.of(new RewardAllocator.Unit<>("p", List.of("a", "b", "c"))), new Random(11));
|
||||
assertEquals(BigInteger.ONE, one.values().stream().reduce(BigInteger.ZERO, BigInteger::add));
|
||||
assertEquals(1, one.values().stream().filter(BigInteger.ONE::equals).count());
|
||||
assertEquals(2, one.values().stream().filter(BigInteger.ZERO::equals).count());
|
||||
assertThrows(IllegalArgumentException.class, () -> RewardAllocator.allocate(BigInteger.ONE,
|
||||
List.of(new RewardAllocator.Unit<>("x", List.of("same")),
|
||||
new RewardAllocator.Unit<>("y", List.of("same"))), new Random(1)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user