Compare commits

...
8 Commits
Author SHA1 Message Date
dmg 61bc67da3b fix(shop): simplify armor market labels
Release / release (push) Successful in 10m48s
2026-09-21 10:54:54 -04:00
dmg 1cc1012ecd fix(shop): allow Armorer use with bound controls
Release / release (push) Successful in 10m7s
2026-09-21 10:24:44 -04:00
dmg 396baf473f fix(party): allow Registrar use with bound controls
Release / release (push) Successful in 10m14s
2026-09-21 10:17:56 -04:00
dmg 1468db0533 feat(shop): add shared daily armor market
Release / release (push) Successful in 10m16s
2026-09-21 09:48:45 -04:00
dmg f9ab5b6e46 feat(gear): add permanent character armor
Release / release (push) Successful in 10m2s
2026-09-21 08:24:22 -04:00
dmg 0042f539a5 feat(party): persist character-owned parties
Release / release (push) Successful in 9m39s
2026-09-21 07:46:58 -04:00
dmg 9d6b945f82 fix(economy): persist PvP coin escrow
Release / release (push) Successful in 9m14s
2026-09-21 00:58:14 -04:00
dmg 5c5f774dea feat(economy): add character coins and exact rewards
Release / release (push) Successful in 5m11s
2026-09-21 00:56:09 -04:00
92 changed files with 3844 additions and 590 deletions

No files matched your search

@@ -36,7 +36,19 @@ public final class FantasyStickPlugin extends JavaPlugin {
private EquipmentSessionEvents sessionEvents;
private games.dmg.fantasystick.book.CharacterBookController books;
private games.dmg.fantasystick.party.PartyRoster parties;
private games.dmg.fantasystick.party.CharacterPartyDirectory characterParties;
private games.dmg.fantasystick.party.PartyRegistrarDirectory partyRegistrars;
private games.dmg.fantasystick.party.PartyRegistrarController partyRegistrarController;
private games.dmg.fantasystick.party.PartyManagement partyManagement;
private games.dmg.fantasystick.party.PartyRegistrarMenus partyRegistrarMenus;
private games.dmg.fantasystick.party.PartyEvents partyEvents;
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
private games.dmg.fantasystick.shop.ArmorShop armorShop;
private games.dmg.fantasystick.shop.ArmorShopMenus armorShopMenus;
private org.bukkit.scheduler.BukkitTask armorMarketPulse;
private org.bukkit.scheduler.BukkitTask armorMenuPulse;
private games.dmg.fantasystick.party.PartyCombat partyCombat;
private EquipmentAdminCommand admin;
private volatile Object lifetime;
@@ -98,6 +110,9 @@ 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.PartyEconomySettings partyEconomySettings;
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
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;
@@ -141,6 +156,14 @@ 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 partyEconomyConfiguration = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.PartyEconomySettings.keys()) partyEconomyConfiguration.put(key, getConfig().getString(key,games.dmg.fantasystick.persistence.PartyEconomySettings.defaultValue(key)));
var gearConfiguration=new java.util.LinkedHashMap<String,String>();for(String key:games.dmg.fantasystick.persistence.GearSettings.keys())gearConfiguration.put(key,getConfig().getString(key,games.dmg.fantasystick.persistence.GearSettings.defaultValue(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(
@@ -196,8 +219,19 @@ 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());
partyEconomySettings = new games.dmg.fantasystick.persistence.PartyEconomySettings(
getDataFolder().toPath(), partyEconomyConfiguration);
gearSettings=new games.dmg.fantasystick.persistence.GearSettings(getDataFolder().toPath(),gearConfiguration);
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());
var armorRandom=java.util.random.RandomGenerator.getDefault();
armorMarket=new games.dmg.fantasystick.shop.ArmorMarket(
new games.dmg.fantasystick.persistence.YamlArmorCatalogRepository(getDataFolder().toPath()),
new games.dmg.fantasystick.persistence.ArmorPurchaseOperationStore(getDataFolder().toPath()),
characters,gearSettings::rules,Clock.systemUTC(),
(minimum,maximum)->(int)armorRandom.nextLong(minimum,(long)maximum+1));
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
task -> getServer().getScheduler().runTask(this, task),
player -> new PlayerSheetDrops(player, event -> getServer().getPluginManager().callEvent(event)),
@@ -235,21 +269,49 @@ public final class FantasyStickPlugin extends JavaPlugin {
switchDelaySettings = new games.dmg.fantasystick.persistence.SwitchDelaySettings(getDataFolder().toPath(), switchDelay);
roleplaySettings = new games.dmg.fantasystick.persistence.RoleplaySettings(getDataFolder().toPath(), roleplayDisabled);
partySettings = new games.dmg.fantasystick.persistence.PartySettings(getDataFolder().toPath(), partyMaximum);
try {
characterParties = new games.dmg.fantasystick.party.CharacterPartyDirectory(
new games.dmg.fantasystick.persistence.YamlPartyRepository(getDataFolder().toPath()), partySettings::maximum);
partyRegistrars = new games.dmg.fantasystick.party.PartyRegistrarDirectory(
new games.dmg.fantasystick.persistence.YamlPartyRegistrarRepository(getDataFolder().toPath()));
armorers=new games.dmg.fantasystick.shop.FantasyArmorerDirectory(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(getDataFolder().toPath()));
} catch (java.io.IOException invalidPartyState) {
throw new java.io.UncheckedIOException("Persistent party state could not be loaded", invalidPartyState);
}
characterParties.suspend();
sheetResourcePack = new games.dmg.fantasystick.presentation.SheetResourcePack(sheetResourcePackSettings,
(player, failure) -> getLogger().log(Level.WARNING, "Sheet resource-pack delivery failed for " + player, failure));
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
sessions.attachSwitchDelay(switchDelaySettings::delay);
sessions.attachRoleplayDisabled(roleplaySettings::disabled);
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
var player = getServer().getPlayer(id);
return player != null && player.isOnline();
}, partySettings::maximum);
parties = new games.dmg.fantasystick.party.PartyRoster(characterParties, sessions, partySettings::maximum);
characterParties.attachEnded(former -> nextTick.accept(() -> {
String message = "Party ended: " + former.displayName();
for (java.util.UUID member : former.members()) {
var activeMember = sessions.activeCharacter(member).filter(sessions::current).orElse(null);
org.bukkit.entity.Player online = activeMember == null ? null : getServer().getPlayer(activeMember.player());
if (online != null && online.isOnline()) online.showTitle(net.kyori.adventure.title.Title.title(
net.kyori.adventure.text.Component.text(message), net.kyori.adventure.text.Component.empty()));
}
}));
sessions.attachPartyDefeat(characterParties::defeat);
var reconcilingParties = characterParties;
var partyCharacters = reconcilingParties.all().stream().flatMap(party -> java.util.stream.Stream.concat(
party.members().stream(), party.invitations().stream().map(games.dmg.fantasystick.party.CharacterParty.Invitation::character))).distinct().toList();
java.util.concurrent.CompletableFuture.allOf(partyCharacters.stream().map(character -> characters.find(character)
.thenCompose(record -> record.isEmpty() || record.orElseThrow().dead()
? reconcilingParties.defeat(character) : java.util.concurrent.CompletableFuture.completedFuture(
games.dmg.fantasystick.party.CharacterPartyDirectory.Defeat.UNCHANGED))).toArray(java.util.concurrent.CompletableFuture[]::new))
.whenComplete((ignored, failure) -> { if (failure == null) reconcilingParties.resume();
else getLogger().log(Level.SEVERE, "Dead-character party reconciliation failed; party authority remains suspended", failure); });
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(
@@ -345,6 +407,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityRegistry.register(greatSword.definition());
abilityRegistry.register(revive.definition());
sessions.attachAbilityRegistry(abilityRegistry);
var gearItems=new games.dmg.fantasystick.items.GearItems(this);sessions.attachGear(gearItems);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
@@ -414,6 +477,24 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityEngine.accept(input);
});
sessionEvents.attachAbilityRegistry(abilityRegistry);
sessionEvents.attachGear(gearItems);
partyManagement = new games.dmg.fantasystick.party.PartyManagement(characterParties, partyRegistrars,
characters, sessions, partyEconomySettings,
new games.dmg.fantasystick.persistence.PartyOperationStore(getDataFolder().toPath()), nextTick,
failure -> getLogger().log(Level.WARNING, "Party Registrar mutation failed", failure));
partyRegistrarMenus = new games.dmg.fantasystick.party.PartyRegistrarMenus(this, partyManagement);
partyRegistrarController = new games.dmg.fantasystick.party.PartyRegistrarController(this, partyRegistrars,
partyRegistrarMenus::open);
armorShop=new games.dmg.fantasystick.shop.ArmorShop(armorers,sessions,armorMarket,nextTick,
failure->getLogger().log(Level.WARNING,"Fantasy Armorer purchase failed",failure));
armorShopMenus=new games.dmg.fantasystick.shop.ArmorShopMenus(this,armorShop);
var localArmorMenus=armorShopMenus;
armorMarket.attachListener(catalog->{if(lifetime!=generation)return;try{nextTick.accept(()->{
if(lifetime==generation)localArmorMenus.refreshOpen();});}
catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Fantasy Armorer menu refresh failed",failure);}});
armorerController=new games.dmg.fantasystick.shop.FantasyArmorerController(this,armorers,armorShopMenus::open);
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|| armorerController.tagged(entity));
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
+ " for player " + problem.player(), problem.cause()), adventureSettings,
@@ -438,6 +519,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachSwitchDelay(switchDelaySettings);
admin.attachRoleplaySettings(roleplaySettings);
admin.attachPartySettings(partySettings);
admin.attachPartyEconomySettings(partyEconomySettings);
admin.attachPartyRegistrars(partyRegistrars, partyRegistrarController);
admin.attachGearSettings(gearSettings);
admin.attachArmorers(armorers,armorerController);
admin.attachArmorMarket(armorMarket);
admin.attachCoinSettings(coinSettings);
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
admin.attachQuickerRegeneration(quickerRegenerationSettings, quickerRegeneration, passiveRegistry);
@@ -457,6 +544,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(chatNames, this);
getServer().getPluginManager().registerEvents(partyEvents, this);
getServer().getPluginManager().registerEvents(partyCombat, this);
getServer().getPluginManager().registerEvents(partyRegistrarController, this);
getServer().getPluginManager().registerEvents(partyRegistrarMenus, this);
getServer().getPluginManager().registerEvents(armorerController,this);
getServer().getPluginManager().registerEvents(armorShopMenus,this);
partyRegistrarController.install();
armorerController.install();
var localArmorMarket=armorMarket;
armorMarketPulse=getServer().getScheduler().runTaskTimer(this,()->{if(lifetime!=generation)return;
localArmorMarket.current().whenComplete((catalog,failure)->{
if(failure!=null&&lifetime==generation)getLogger().log(Level.WARNING,"Scheduled Fantasy Armorer restock check failed",failure);});},20L,20L);
armorMenuPulse=getServer().getScheduler().runTaskTimer(this,localArmorMenus::refreshOpen,1200L,1200L);
partyCombat.install(this);
getServer().getPluginManager().registerEvents(healthSteal, this);
getServer().getPluginManager().registerEvents(combatExperience, this);
@@ -519,6 +617,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingPartyEvents = partyEvents; partyEvents = null;
var closingPartyCombat = partyCombat; partyCombat = null;
var closingParties = parties; parties = null;
var closingCharacterParties = characterParties; characterParties = null;
var closingPartyRegistrars = partyRegistrars; partyRegistrars = null;
var closingPartyRegistrarController = partyRegistrarController; partyRegistrarController = null;
var closingPartyRegistrarMenus = partyRegistrarMenus; partyRegistrarMenus = null;
var closingPartyManagement = partyManagement; partyManagement = null;
var closingArmorers=armorers;armorers=null;var closingArmorerController=armorerController;armorerController=null;
var closingArmorShopMenus=armorShopMenus;armorShopMenus=null;armorShop=null;
var closingArmorMarket=armorMarket;armorMarket=null;var closingArmorMarketPulse=armorMarketPulse;armorMarketPulse=null;
var closingArmorMenuPulse=armorMenuPulse;armorMenuPulse=null;
var closingCrafting = crafting; crafting = null;
var closingGuard = guard; guard = null;
var closingAnvil = anvilNames; anvilNames = null;
@@ -570,6 +677,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
var closingRoleplaySettings = roleplaySettings; roleplaySettings = null;
var closingPartySettings = partySettings; partySettings = null;
var closingPartyEconomySettings = partyEconomySettings; partyEconomySettings = null;
var closingGearSettings=gearSettings;gearSettings=null;
var closingCoinSettings = coinSettings; coinSettings = null;
var closingSheetResourcePackSettings = sheetResourcePackSettings; sheetResourcePackSettings = null;
var closingSheetResourcePack = sheetResourcePack; sheetResourcePack = null;
var closingTemporaryHealth = temporaryHealth; temporaryHealth = null;
@@ -628,6 +738,9 @@ 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 (closingPartyEconomySettings != null) cleanup("party economy configuration", closingPartyEconomySettings::close);
if(closingGearSettings!=null)cleanup("gear configuration",closingGearSettings::close);
if (closingCoinSettings != null) cleanup("coin configuration", closingCoinSettings::close);
if (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);
@@ -635,6 +748,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingQuickerRegenerationIISettings != null) cleanup("Quicker Regeneration II configuration", closingQuickerRegenerationIISettings::close);
if (closingHealthStealSettings != null) cleanup("Health Steal configuration", closingHealthStealSettings::close);
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
closingCharacterParties == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingCharacterParties.drained().toCompletableFuture(),
closingPartyRegistrars == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartyRegistrars.drained().toCompletableFuture(),
closingPartyManagement == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartyManagement.drained().toCompletableFuture(),
closingArmorers==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingArmorers.drained().toCompletableFuture(),
closingArmorMarket==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingArmorMarket.drained().toCompletableFuture(),
closingGearSettings==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingGearSettings.drained().toCompletableFuture(),
closingPartyEconomySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartyEconomySettings.drained().toCompletableFuture(),
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
@@ -715,6 +839,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
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 (closingCharacterParties != null) cleanup("character parties", closingCharacterParties::close);
if (closingPartyRegistrarMenus != null) cleanup("party registrar menus", closingPartyRegistrarMenus::close);
if (closingPartyManagement != null) cleanup("party management", closingPartyManagement::close);
if (closingPartyRegistrarController != null) cleanup("party registrar entities", closingPartyRegistrarController::close);
if (closingPartyRegistrars != null) cleanup("party registrars", closingPartyRegistrars::close);
if(closingArmorMarketPulse!=null)cleanup("armor market schedule",closingArmorMarketPulse::cancel);
if(closingArmorMenuPulse!=null)cleanup("armor menu schedule",closingArmorMenuPulse::cancel);
if(closingArmorShopMenus!=null)cleanup("armor shop menus",closingArmorShopMenus::close);
if(closingArmorerController!=null)cleanup("armorer entities",closingArmorerController::close);
if(closingArmorMarket!=null)cleanup("armor market",closingArmorMarket::close);
if(closingArmorers!=null)cleanup("armorers",closingArmorers::close);
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
if (closingEvents != null) cleanup("session events", closingEvents::close);
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
@@ -724,6 +859,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingChat);
unregister(closingPartyEvents);
unregister(closingPartyCombat);
unregister(closingPartyRegistrarController);
unregister(closingPartyRegistrarMenus);
unregister(closingArmorerController);unregister(closingArmorShopMenus);
unregister(closingCombatExperience);
unregister(closingHealthSteal);
unregister(closingFireball);
@@ -138,6 +138,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
var pages = new java.util.ArrayList<Component>(allOverview.subList(start, end));
if(end==allOverview.size())pages.add(gearPage(record));
if (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
var definitions = abilities.definitions().stream()
@@ -198,12 +199,15 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
pages.add(navigation);
}
pages.addAll(partyControls.pages(input));
if (end == allOverview.size()) pages.addAll(partyControls.pages(input));
meta.pages(pages);
item.setItemMeta(meta);
return item;
}
private Component gearPage(CharacterRecord record){return Component.text("Current gear\n\nHead: Cosmetic\nonly\nChestplate: "+tier(record.gear().chestplate())+"\nLeggings: "+tier(record.gear().leggings())+"\nBoots: "+tier(record.gear().boots()));}
private static String tier(games.dmg.fantasystick.persistence.GearTier tier){String value=tier.name().toLowerCase(java.util.Locale.ROOT);return Character.toUpperCase(value.charAt(0))+value.substring(1);}
private java.util.List<Component> overviewPages(CharacterRecord record, ControlInput input, StatView view) {
var builder = new PageBuilder();
builder.text(record.name());
@@ -219,6 +223,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();
@@ -63,26 +63,14 @@ public final class PartyBookControls implements AutoCloseable {
if (menu == Menu.ROOT) {
Component summary = Component.text("Party\n\n");
if (party.isEmpty()) {
summary = summary.append(build.button("Create party", () -> mutate(input, () -> parties.create(owner).isPresent())))
.append(Component.newline()).append(build.button("Invitations", () -> show(input, Menu.INVITATIONS, 0)));
build.pages.add(summary);
build.pages.add(summary.append(Component.text("No party.\n\nVisit a Party\nRegistrar.\nCreate, join, or\nmanage one.")));
} else {
var group = party.orElseThrow();
boolean leader = group.leader().equals(owner);
summary = summary.append(Component.text(group.members().size() + "/" + parties.maximumSize() + " members\n\n"))
.append(build.button("Leave party", () -> mutate(input, () -> parties.leave(owner, group.id()))));
if (leader) {
summary = summary.append(Component.newline())
.append(build.button("Disband party", () -> mutate(input, () -> parties.disband(owner, group.id()))));
if (group.members().size() < parties.maximumSize()) summary = summary.append(Component.newline())
.append(build.button("Invite players", () -> show(input, Menu.PLAYERS, 0)));
}
summary = summary.append(Component.text(group.characters().size() + " members\n" + group.members().size() + " active\n\nManage this party\nonly at a Party\nRegistrar."));
build.pages.add(summary);
for (var member : group.members()) {
Component row = Component.text(member.equals(group.leader()) ? "Leader\n\n" : "Member\n\n").append(names.apply(member));
if (leader && !member.equals(owner)) row = row.append(Component.text("\n\n"))
.append(build.button("Kick", () -> mutate(input, () -> parties.kick(owner, group.id(), member))));
build.pages.add(row);
for (var member : group.characters()) {
Component label=parties.activePlayer(member).map(names).orElse(Component.text("Character\n"+member.toString().substring(0,8)));
build.pages.add(Component.text(member.equals(group.leaderCharacter()) ? "Leader\n\n" : "Member\n\n").append(label));
}
}
} else if (menu == Menu.PLAYERS) {
@@ -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());
}
}
@@ -131,7 +131,7 @@ public final class EquipmentStorage {
for (int slot : EQUIPMENT_SLOTS) {
if (slot == 39) continue;
var current = inventory.getItem(slot);
if (!empty(current) && !(slot < 9 && ownedControl.test(current.clone()))) return RestoreResult.OCCUPIED_SLOT;
if (!empty(current) && !ownedControl.test(current.clone())) return RestoreResult.OCCUPIED_SLOT;
}
var currentHead = inventory.getItem(39);
var displacedHead = empty(currentHead) ? null : currentHead.clone();
@@ -0,0 +1,14 @@
package games.dmg.fantasystick.items;
import games.dmg.fantasystick.persistence.*;import java.util.*;import net.kyori.adventure.text.Component;import org.bukkit.*;import org.bukkit.inventory.*;import org.bukkit.persistence.PersistentDataType;import org.bukkit.plugin.Plugin;
/** Native unbreakable session-bound projection of permanent character armor. */
public final class GearItems{
private final NamespacedKey typeKey,ownerKey,sessionKey,slotKey,tierKey;
public GearItems(Plugin plugin){typeKey=new NamespacedKey(plugin,"character_gear");ownerKey=new NamespacedKey(plugin,"character_gear_owner");sessionKey=new NamespacedKey(plugin,"character_gear_session");slotKey=new NamespacedKey(plugin,"character_gear_slot");tierKey=new NamespacedKey(plugin,"character_gear_tier");}
public ItemStack create(GearSlot slot,GearTier tier,UUID owner,UUID session){if(tier==GearTier.NONE)return null;var item=new ItemStack(material(slot,tier));var meta=item.getItemMeta();meta.setUnbreakable(true);meta.setMaxStackSize(1);meta.displayName(Component.text("Fantasy "+title(tier)+" "+title(slot)));var data=meta.getPersistentDataContainer();data.set(typeKey,PersistentDataType.INTEGER,1);data.set(ownerKey,PersistentDataType.STRING,owner.toString());data.set(sessionKey,PersistentDataType.STRING,session.toString());data.set(slotKey,PersistentDataType.STRING,slot.name());data.set(tierKey,PersistentDataType.STRING,tier.name());item.setItemMeta(meta);return item;}
public boolean gear(ItemStack item){return item!=null&&!item.isEmpty()&&Integer.valueOf(1).equals(item.getItemMeta().getPersistentDataContainer().get(typeKey,PersistentDataType.INTEGER));}
public boolean owned(ItemStack item,UUID owner,UUID session){if(!gear(item))return false;var data=item.getItemMeta().getPersistentDataContainer();return owner.toString().equals(data.get(ownerKey,PersistentDataType.STRING))&&session.toString().equals(data.get(sessionKey,PersistentDataType.STRING));}
public void install(PlayerInventory inventory,CharacterRecord record,UUID owner,UUID session){inventory.setChestplate(create(GearSlot.CHESTPLATE,record.gear().chestplate(),owner,session));inventory.setLeggings(create(GearSlot.LEGGINGS,record.gear().leggings(),owner,session));inventory.setBoots(create(GearSlot.BOOTS,record.gear().boots(),owner,session));}
private static String title(Enum<?> value){String text=value.name().toLowerCase(Locale.ROOT).replace('_',' ');return Character.toUpperCase(text.charAt(0))+text.substring(1);}
private static Material material(GearSlot slot,GearTier tier){return Material.valueOf((tier==GearTier.GOLD?"GOLDEN":tier.name())+"_"+switch(slot){case CHESTPLATE->"CHESTPLATE";case LEGGINGS->"LEGGINGS";case BOOTS->"BOOTS";});}
}
@@ -37,9 +37,11 @@ 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()));
pages.addAll(BookPagination.pages("Current gear\nHead: Cosmetic only\nChestplate: "+tier(character.gear().chestplate())+"\nLeggings: "+tier(character.gear().leggings())+"\nBoots: "+tier(character.gear().boots())));
if (character.learned().isEmpty()) pages.addAll(BookPagination.pages("Abilities: None"));
character.learned().stream().sorted().forEach(ability -> {
var slots = new ArrayList<String>();
@@ -73,6 +75,11 @@ public final class TailBooks {
return item;
}
private static String tier(games.dmg.fantasystick.persistence.GearTier tier) {
String value = tier.name().toLowerCase(java.util.Locale.ROOT);
return Character.toUpperCase(value.charAt(0)) + value.substring(1);
}
public Optional<Identity> identify(ItemStack item) {
if (item == null || item.getType() != Material.WRITTEN_BOOK || !item.hasItemMeta()) return Optional.empty();
var data = item.getItemMeta().getPersistentDataContainer();
@@ -0,0 +1,33 @@
package games.dmg.fantasystick.party;
import java.math.BigInteger;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** Durable party identity; every person reference is a transferable character UUID. */
public record CharacterParty(UUID id, BigInteger revision, String displayName, String canonicalName,
UUID leader, Set<UUID> members, List<Invitation> invitations,UUID lastPaidOperation) {
public CharacterParty(UUID id,BigInteger revision,String displayName,String canonicalName,UUID leader,Set<UUID> members,List<Invitation> invitations){this(id,revision,displayName,canonicalName,leader,members,invitations,null);}
public record Invitation(UUID id, UUID character) {
public Invitation { Objects.requireNonNull(id); Objects.requireNonNull(character); }
}
public CharacterParty {
Objects.requireNonNull(id); Objects.requireNonNull(revision); Objects.requireNonNull(displayName);
Objects.requireNonNull(canonicalName); Objects.requireNonNull(leader);
members = Set.copyOf(members); invitations = List.copyOf(invitations);
var memberIds = members;
var parsed = PartyNames.parse(displayName);
var invitationIds = new HashSet<UUID>();
var targets = new HashSet<UUID>();
if (revision.signum() < 0 || parsed.isEmpty() || !parsed.orElseThrow().canonical().equals(canonicalName)
|| !members.contains(leader) || members.isEmpty()
|| invitations.stream().anyMatch(invitation -> !invitationIds.add(invitation.id())
|| !targets.add(invitation.character()) || memberIds.contains(invitation.character()))) {
throw new IllegalArgumentException("Invalid character party");
}
}
}
@@ -0,0 +1,264 @@
package games.dmg.fantasystick.party;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.PartyOperationStore;
import games.dmg.fantasystick.persistence.YamlPartyRepository;
import java.io.IOException;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.IntSupplier;
/** Serialized durable character-party mutations with an immutable acknowledged read snapshot. */
public final class CharacterPartyDirectory implements AutoCloseable {
public enum Status { APPLIED, DUPLICATE, NAME_TAKEN, FULL, ALREADY_MEMBER, INVALID }
public enum Defeat { UNCHANGED, MEMBER_REMOVED, PARTY_DISBANDED }
public record Offer(UUID id, UUID party, UUID character) { }
public record PaidResult(Result result,CharacterStorage.CoinSpend spend){}
public record Result(Status status, Optional<CharacterParty> party, Optional<Offer> invitation) {
static Result of(Status status) { return new Result(status, Optional.empty(), Optional.empty()); }
static Result party(Status status, CharacterParty party) {
return new Result(status, Optional.of(party), Optional.empty());
}
static Result invite(Status status, CharacterParty party, CharacterParty.Invitation invitation) {
return new Result(status, Optional.of(party), Optional.of(new Offer(invitation.id(), party.id(), invitation.character())));
}
}
private final YamlPartyRepository repository;
private final IntSupplier maximumSize;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-party-persistence"));
private volatile List<CharacterParty> snapshot;
private volatile java.util.function.Consumer<CharacterParty> ended=ignored->{};
private volatile boolean closed;
private volatile boolean ready=true;
private volatile CompletableFuture<Void> readiness=CompletableFuture.completedFuture(null);
private int pending;
private final CompletableFuture<Void> drained=new CompletableFuture<>();
public CharacterPartyDirectory(YamlPartyRepository repository, IntSupplier maximumSize) throws IOException {
this.repository = Objects.requireNonNull(repository);
this.maximumSize = Objects.requireNonNull(maximumSize);
snapshot = repository.load();
maximumSize();
}
public List<CharacterParty> all(){return snapshot;}
public synchronized void suspend(){if(!ready)return;ready=false;readiness=new CompletableFuture<>();}
public synchronized void resume(){ready=true;readiness.complete(null);}public boolean ready(){return ready;}public java.util.concurrent.CompletionStage<Void> readiness(){return readiness.minimalCompletionStage();}
public void attachEnded(java.util.function.Consumer<CharacterParty> notification){ended=Objects.requireNonNull(notification);}
public Optional<CharacterParty> partyOf(UUID character) {
return snapshot.stream().filter(party -> party.members().contains(character)).findFirst();
}
public Optional<CharacterParty> partyById(UUID id) {
return snapshot.stream().filter(party -> party.id().equals(id)).findFirst();
}
public Optional<CharacterParty> partyByCanonicalName(String canonical) {
return snapshot.stream().filter(party -> party.canonicalName().equals(canonical)).findFirst();
}
public List<Offer> invitationsFor(UUID character) {
return snapshot.stream().flatMap(party -> party.invitations().stream()
.filter(invitation -> invitation.character().equals(character))
.map(invitation -> new Offer(invitation.id(), party.id(), character)))
.sorted(Comparator.comparing(Offer::id)).toList();
}
public CompletableFuture<Result> create(UUID leader, String input) {
return create(UUID.randomUUID(),leader,input);
}
public CompletableFuture<Result> create(UUID partyId, UUID leader, String input) {
return submit(() -> createNow(partyId,leader,input));
}
private Result createNow(UUID partyId,UUID leader,String input)throws IOException{
var completed=partyById(partyId);if(completed.isPresent())return Result.party(Status.DUPLICATE,completed.orElseThrow());
var name=PartyNames.parse(input);if(name.isEmpty())return Result.of(Status.INVALID);
if(partyOf(leader).isPresent())return Result.of(Status.ALREADY_MEMBER);
if(partyByCanonicalName(name.orElseThrow().canonical()).isPresent())return Result.of(Status.NAME_TAKEN);
var party=new CharacterParty(partyId,BigInteger.ZERO,name.orElseThrow().display(),name.orElseThrow().canonical(),leader,Set.of(leader),List.of());var next=new ArrayList<>(snapshot);next.add(party);save(next);return Result.party(Status.APPLIED,party);
}
public CompletableFuture<PaidResult> paidCreate(PartyOperationStore.Plan plan,CharacterStorage characters){return submit(()->{
if(plan.kind()!=PartyOperationStore.Kind.CREATE)throw new IllegalArgumentException("Expected create operation");
var existing=partyById(plan.id());if(existing.isPresent()){var spent=debit(plan,characters,plan.expectedRevision());if(refunded(plan,spent)&&plan.id().equals(existing.orElseThrow().lastPaidOperation())){remove(existing.orElseThrow());return new PaidResult(Result.of(Status.INVALID),new CharacterStorage.CoinSpend(CharacterStorage.CoinSpendStatus.STALE,spent.record()));}return new PaidResult(Result.party(Status.DUPLICATE,existing.orElseThrow()),spent);}
var name=PartyNames.parse(plan.name());if(name.isEmpty())return new PaidResult(Result.of(Status.INVALID),null);
if(partyOf(plan.actor()).isPresent())return new PaidResult(Result.of(Status.ALREADY_MEMBER),null);
if(partyByCanonicalName(name.orElseThrow().canonical()).isPresent())return new PaidResult(Result.of(Status.NAME_TAKEN),null);
var spent=debit(plan,characters,plan.expectedRevision());if(spent.status()!=CharacterStorage.CoinSpendStatus.APPLIED&&spent.status()!=CharacterStorage.CoinSpendStatus.DUPLICATE)return new PaidResult(Result.of(Status.INVALID),spent);if(refunded(plan,spent))return new PaidResult(Result.of(Status.INVALID),new CharacterStorage.CoinSpend(CharacterStorage.CoinSpendStatus.STALE,spent.record()));
var party=new CharacterParty(plan.id(),BigInteger.ZERO,name.orElseThrow().display(),name.orElseThrow().canonical(),plan.actor(),Set.of(plan.actor()),List.of(),plan.id());var next=new ArrayList<>(snapshot);next.add(party);try{save(next);return new PaidResult(Result.party(Status.APPLIED,party),spent);}catch(IOException persistenceFailure){return compensate(plan,characters,persistenceFailure);}
});}
public CompletableFuture<Result> invite(UUID leader, UUID partyId, UUID target) {
return submit(() -> {
var party = managed(leader, partyId);
if (party.isEmpty() || partyOf(target).isPresent() || leader.equals(target)) return Result.of(Status.INVALID);
var existing = party.orElseThrow().invitations().stream()
.filter(invitation -> invitation.character().equals(target)).findFirst();
if (existing.isPresent()) return Result.invite(Status.DUPLICATE, party.orElseThrow(), existing.orElseThrow());
var invitation = new CharacterParty.Invitation(UUID.randomUUID(), target);
var invitations = new ArrayList<>(party.orElseThrow().invitations()); invitations.add(invitation);
var updated = copy(party.orElseThrow(), party.orElseThrow().leader(), party.orElseThrow().members(), invitations);
replace(party.orElseThrow(), updated);
return Result.invite(Status.APPLIED, updated, invitation);
});
}
public CompletableFuture<Result> accept(UUID character, UUID invitationId) {
return submit(() -> {
CharacterParty party = null; CharacterParty.Invitation invitation = null;
for (var candidate : snapshot) for (var offer : candidate.invitations()) {
if (offer.id().equals(invitationId)) { party = candidate; invitation = offer; }
}
if (party == null || !invitation.character().equals(character)) return Result.of(Status.INVALID);
if (partyOf(character).isPresent()) return Result.of(Status.ALREADY_MEMBER);
if (party.members().size() >= maximumSize()) return Result.of(Status.FULL);
var members = new java.util.HashSet<>(party.members()); members.add(character);
var updated = copy(party, party.leader(), members,
party.invitations().stream().filter(value -> !value.id().equals(invitationId)).toList());
var next = new ArrayList<CharacterParty>();
for (var candidate : snapshot) {
var base = candidate.id().equals(party.id()) ? updated : candidate;
var offers = base.invitations().stream().filter(value -> !value.character().equals(character)).toList();
next.add(offers.size() == base.invitations().size() ? base
: copy(base, base.leader(), base.members(), offers));
}
save(next);
return Result.party(Status.APPLIED, partyById(party.id()).orElseThrow());
});
}
public CompletableFuture<Result> decline(UUID character, UUID invitationId) {
return submit(() -> {
for (var party : snapshot) {
var invitation = party.invitations().stream().filter(value -> value.id().equals(invitationId)).findFirst();
if (invitation.isEmpty() || !invitation.orElseThrow().character().equals(character)) continue;
var updated = copy(party, party.leader(), party.members(),
party.invitations().stream().filter(value -> !value.id().equals(invitationId)).toList());
replace(party, updated); return Result.party(Status.APPLIED, updated);
}
return Result.of(Status.INVALID);
});
}
public CompletableFuture<Result> cancel(UUID leader, UUID partyId, UUID invitationId) {
return submit(() -> {
var party = managed(leader, partyId);
if (party.isEmpty() || party.orElseThrow().invitations().stream().noneMatch(value -> value.id().equals(invitationId))) {
return Result.of(Status.INVALID);
}
var updated = copy(party.orElseThrow(), leader, party.orElseThrow().members(),
party.orElseThrow().invitations().stream().filter(value -> !value.id().equals(invitationId)).toList());
replace(party.orElseThrow(), updated); return Result.party(Status.APPLIED, updated);
});
}
public CompletableFuture<Result> kick(UUID leader, UUID partyId, UUID member) {
return submit(() -> {
var party = managed(leader, partyId);
if (party.isEmpty() || member.equals(leader) || !party.orElseThrow().members().contains(member)) {
return Result.of(Status.INVALID);
}
var members = new java.util.HashSet<>(party.orElseThrow().members()); members.remove(member);
var updated = copy(party.orElseThrow(), leader, members, party.orElseThrow().invitations());
replace(party.orElseThrow(), updated); return Result.party(Status.APPLIED, updated);
});
}
public CompletableFuture<Result> leave(UUID character, UUID partyId) {
return submit(() -> {
var party = partyOf(character).filter(value -> value.id().equals(partyId));
if (party.isEmpty()) return Result.of(Status.INVALID);
if (party.orElseThrow().leader().equals(character)) {
remove(party.orElseThrow()); return Result.party(Status.APPLIED, party.orElseThrow());
}
var members = new java.util.HashSet<>(party.orElseThrow().members()); members.remove(character);
var updated = copy(party.orElseThrow(), party.orElseThrow().leader(), members,
party.orElseThrow().invitations());
replace(party.orElseThrow(), updated); return Result.party(Status.APPLIED, updated);
});
}
public CompletableFuture<Result> disband(UUID leader, UUID partyId) {
return submit(() -> {
var party = managed(leader, partyId);
if (party.isEmpty()) return Result.of(Status.INVALID);
remove(party.orElseThrow()); return Result.party(Status.APPLIED, party.orElseThrow());
});
}
public CompletableFuture<Result> transfer(UUID leader, UUID partyId, UUID target) {
return submit(() -> {
var existing=partyById(partyId);
if(existing.isPresent()&&existing.orElseThrow().leader().equals(target)
&&existing.orElseThrow().members().contains(leader))return Result.party(Status.DUPLICATE,existing.orElseThrow());
var party = managed(leader, partyId);
if (party.isEmpty() || target.equals(leader) || !party.orElseThrow().members().contains(target)) {
return Result.of(Status.INVALID);
}
var updated = copy(party.orElseThrow(), target, party.orElseThrow().members(), party.orElseThrow().invitations());
replace(party.orElseThrow(), updated); return Result.party(Status.APPLIED, updated);
});
}
public CompletableFuture<PaidResult> paidTransfer(PartyOperationStore.Plan plan,CharacterStorage characters){return submit(()->{
if(plan.kind()!=PartyOperationStore.Kind.TRANSFER)throw new IllegalArgumentException("Expected transfer operation");
var existing=partyById(plan.party());
if(existing.isPresent()&&plan.id().equals(existing.orElseThrow().lastPaidOperation())){var spent=debit(plan,characters,plan.expectedRevision());if(refunded(plan,spent)){var reverted=new CharacterParty(existing.orElseThrow().id(),existing.orElseThrow().revision().add(BigInteger.ONE),existing.orElseThrow().displayName(),existing.orElseThrow().canonicalName(),plan.actor(),existing.orElseThrow().members(),existing.orElseThrow().invitations(),null);replace(existing.orElseThrow(),reverted);return new PaidResult(Result.of(Status.INVALID),new CharacterStorage.CoinSpend(CharacterStorage.CoinSpendStatus.STALE,spent.record()));}return new PaidResult(Result.party(Status.DUPLICATE,existing.orElseThrow()),spent);}
if(existing.isEmpty()||!existing.orElseThrow().revision().equals(plan.expectedPartyRevision()))return new PaidResult(Result.of(Status.INVALID),null);
var party=managed(plan.actor(),plan.party());if(party.isEmpty()||plan.target().equals(plan.actor())||!party.orElseThrow().members().contains(plan.target()))return new PaidResult(Result.of(Status.INVALID),null);
var spent=debit(plan,characters,plan.expectedRevision());if(spent.status()!=CharacterStorage.CoinSpendStatus.APPLIED&&spent.status()!=CharacterStorage.CoinSpendStatus.DUPLICATE)return new PaidResult(Result.of(Status.INVALID),spent);if(refunded(plan,spent))return new PaidResult(Result.of(Status.INVALID),new CharacterStorage.CoinSpend(CharacterStorage.CoinSpendStatus.STALE,spent.record()));
var updated=new CharacterParty(party.orElseThrow().id(),party.orElseThrow().revision().add(BigInteger.ONE),party.orElseThrow().displayName(),party.orElseThrow().canonicalName(),plan.target(),party.orElseThrow().members(),party.orElseThrow().invitations(),plan.id());try{replace(party.orElseThrow(),updated);}catch(IOException persistenceFailure){return compensate(plan,characters,persistenceFailure);}return new PaidResult(Result.party(Status.APPLIED,updated),spent);
});}
private CharacterStorage.CoinSpend debit(PartyOperationStore.Plan plan,CharacterStorage characters,BigInteger expected){return characters.spendCoins(plan.actor(),expected,plan.id(),plan.amount()).join();}
private static boolean refunded(PartyOperationStore.Plan plan,CharacterStorage.CoinSpend spent){return spent.record().map(value->value.rewardReceipts().contains(PartyOperationStore.refundId(plan.id()))).orElse(false);}
private PaidResult compensate(PartyOperationStore.Plan plan,CharacterStorage characters,IOException failure)throws IOException{var refund=characters.refundCoins(plan.actor(),plan.id(),PartyOperationStore.refundId(plan.id()),plan.amount()).join();if(refund.status()==CharacterStorage.CoinRefundStatus.APPLIED||refund.status()==CharacterStorage.CoinRefundStatus.DUPLICATE)return new PaidResult(Result.of(Status.INVALID),new CharacterStorage.CoinSpend(CharacterStorage.CoinSpendStatus.STALE,refund.record()));throw failure;}
public CompletableFuture<Defeat> defeat(UUID character) {
return submit(() -> {
var membership=partyOf(character);var endedParty=membership.filter(value->value.leader().equals(character)).orElse(null);var next=new ArrayList<CharacterParty>();boolean changed=false;
for(var party:snapshot){if(endedParty!=null&&party.id().equals(endedParty.id())){changed=true;continue;}var members=party.members();if(membership.isPresent()&&party.id().equals(membership.orElseThrow().id())){var reduced=new java.util.HashSet<>(members);reduced.remove(character);members=Set.copyOf(reduced);changed=true;}var invitations=party.invitations().stream().filter(value->!value.character().equals(character)).toList();if(invitations.size()!=party.invitations().size())changed=true;next.add(members.equals(party.members())&&invitations.equals(party.invitations())?party:copy(party,party.leader(),members,invitations));}
if(changed)save(next);if(endedParty!=null){try{ended.accept(endedParty);}catch(RuntimeException ignored){}return Defeat.PARTY_DISBANDED;}return membership.isPresent()?Defeat.MEMBER_REMOVED:Defeat.UNCHANGED;
});
}
private Optional<CharacterParty> managed(UUID leader, UUID id) {
return partyById(id).filter(party -> party.leader().equals(leader));
}
private CharacterParty copy(CharacterParty old, UUID leader, Set<UUID> members,
List<CharacterParty.Invitation> invitations) {
return new CharacterParty(old.id(), old.revision().add(BigInteger.ONE), old.displayName(), old.canonicalName(),
leader, members, invitations,old.lastPaidOperation());
}
private void replace(CharacterParty old, CharacterParty updated) throws IOException {
var next = snapshot.stream().map(party -> party.id().equals(old.id()) ? updated : party).toList(); save(next);
}
private void remove(CharacterParty party) throws IOException {
save(snapshot.stream().filter(candidate -> !candidate.id().equals(party.id())).toList());
try{ended.accept(party);}catch(RuntimeException ignored){}
}
private void save(List<CharacterParty> next) throws IOException {
repository.save(next); snapshot = List.copyOf(next);
}
private int maximumSize() {
int value = maximumSize.getAsInt(); if (value < 1) throw new IllegalStateException("Party maximum must be positive");
return value;
}
private <T> CompletableFuture<T> submit(Callable<T> operation) {
var future = new CompletableFuture<T>();
synchronized(this){if (closed) { future.completeExceptionally(new IllegalStateException("Party directory is closed")); return future; }pending++;}
worker.execute(() -> { try { future.complete(operation.call()); } catch (Throwable failure) { future.completeExceptionally(failure); } finally{finished();} });
return future;
}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}
public java.util.concurrent.CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
@Override public synchronized void close() { closed = true; worker.shutdown();if(pending==0)drained.complete(null); }
}
@@ -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();
}
}
@@ -0,0 +1,35 @@
package games.dmg.fantasystick.party;
import games.dmg.fantasystick.persistence.*;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.util.*;
import java.util.function.Consumer;
import org.bukkit.entity.Player;
/** Registrar-authorized mutations with durable operation plans spanning coin and party files. */
public final class PartyManagement implements AutoCloseable {
public enum Status{APPLIED,INVALID_SESSION,TOO_FAR,INVALID_NAME,ALREADY_MEMBER,NAME_TAKEN,INSUFFICIENT,FAILED}
public record Outcome(Status status,Optional<CharacterParty> party){}
private record Context(EquipmentSessions.Active active,CharacterRecord record,PartyRegistrar registrar){}
private final CharacterPartyDirectory parties;private final PartyRegistrarDirectory registrars;private final CharacterStorage characters;private final EquipmentSessions sessions;private final PartyEconomySettings prices;private final PartyOperationStore operations;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private volatile boolean closed;private volatile boolean recovered;private final java.util.concurrent.CompletableFuture<Void> recovery=new java.util.concurrent.CompletableFuture<>();
public PartyManagement(CharacterPartyDirectory parties,PartyRegistrarDirectory registrars,CharacterStorage characters,EquipmentSessions sessions,PartyEconomySettings prices,PartyOperationStore operations,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.parties=Objects.requireNonNull(parties);this.registrars=Objects.requireNonNull(registrars);this.characters=Objects.requireNonNull(characters);this.sessions=Objects.requireNonNull(sessions);this.prices=Objects.requireNonNull(prices);this.operations=Objects.requireNonNull(operations);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);parties.readiness().thenCompose(ignored->operations.load()).thenCompose(plans->java.util.concurrent.CompletableFuture.allOf(plans.stream().map(plan->execute(plan,null,null).toCompletableFuture()).toArray(java.util.concurrent.CompletableFuture[]::new))).whenComplete((ignored,failure)->{if(failure==null){recovered=true;recovery.complete(null);}else{report(failure);recovery.completeExceptionally(failure);}});}
public void register(Player player,UUID registrarId,String name,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var parsed=PartyNames.parse(name);if(parsed.isEmpty()){result.accept(new Outcome(Status.INVALID_NAME,Optional.empty()));return;}var captured=context.orElseThrow();if(parties.partyOf(captured.record().id()).isPresent()){result.accept(new Outcome(Status.ALREADY_MEMBER,Optional.empty()));return;}if(parties.partyByCanonicalName(parsed.orElseThrow().canonical()).isPresent()){result.accept(new Outcome(Status.NAME_TAKEN,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.CREATE,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.registrationPrice()),parsed.orElseThrow().display(),null,null,null);prepare(plan,captured,result);}
public void transfer(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var party=parties.partyOf(captured.record().id()).orElse(null);if(party==null||!party.leader().equals(captured.record().id())||!party.members().contains(target)||target.equals(captured.record().id())){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.TRANSFER,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.leaderChangePrice()),null,party.id(),target,party.revision());prepare(plan,captured,result);}
private void prepare(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result){operations.prepare(plan).whenComplete((saved,failure)->{if(failure!=null){fail(failure,result);return;}execute(saved,context,result);});}
private java.util.concurrent.CompletionStage<Void> execute(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result){var done=new java.util.concurrent.CompletableFuture<Void>();parties.readiness().thenCompose(ignored->plan.kind()==PartyOperationStore.Kind.CREATE?parties.paidCreate(plan,characters):parties.paidTransfer(plan,characters)).whenComplete((paid,failure)->{if(failure!=null){if(result!=null)fail(failure,result);else report(failure);done.completeExceptionally(failure);return;}var spent=paid.spend();var changed=paid.result();if(spent!=null&&(spent.status()==CharacterStorage.CoinSpendStatus.INSUFFICIENT||spent.status()==CharacterStorage.CoinSpendStatus.UNAVAILABLE)){finish(plan,context,result,spent,new Outcome(Status.INSUFFICIENT,Optional.empty()),done);return;}if(spent==null){finish(plan,context,result,null,new Outcome(map(changed.status()),changed.party()),done);return;}if(spent.status()!=CharacterStorage.CoinSpendStatus.APPLIED&&spent.status()!=CharacterStorage.CoinSpendStatus.DUPLICATE){finish(plan,context,result,spent,new Outcome(Status.FAILED,Optional.empty()),done);return;}if(changed.status()!=CharacterPartyDirectory.Status.APPLIED&&changed.status()!=CharacterPartyDirectory.Status.DUPLICATE){finish(plan,context,result,spent,new Outcome(map(changed.status()),changed.party()),done);return;}finish(plan,context,result,spent,new Outcome(Status.APPLIED,changed.party()),done);});return done.minimalCompletionStage();}
private void finish(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result,CharacterStorage.CoinSpend spent,Outcome outcome,java.util.concurrent.CompletableFuture<Void> done){operations.complete(plan.id()).whenComplete((ignored,failure)->{if(failure!=null){if(result!=null)fail(failure,result);else report(failure);done.completeExceptionally(failure);return;}if(result!=null)publish(()->{if(context!=null&&spent!=null&&spent.record().isPresent())sessions.acknowledgeProgress(context.active(),context.record().revision(),spent.record().orElseThrow());result.accept(outcome);});done.complete(null);});}
private static Status map(CharacterPartyDirectory.Status status){return switch(status){case NAME_TAKEN->Status.NAME_TAKEN;case ALREADY_MEMBER->Status.ALREADY_MEMBER;default->Status.FAILED;};}
public void invite(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var active=sessions.activeCharacter(target).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(record==null||parties.partyOf(target).isPresent()){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null||!party.leader().equals(context.orElseThrow().record().id())){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}parties.invite(context.orElseThrow().record().id(),party.id(),target).whenComplete((value,failure)->complete(value,failure,result));}
public void accept(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutate(player,registrarId,c->parties.accept(c.record().id(),invitation),result);}public void decline(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutate(player,registrarId,c->parties.decline(c.record().id(),invitation),result);}public void cancel(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.cancel(c.record().id(),p.id(),invitation),result);}public void leave(Player player,UUID registrarId,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.leave(c.record().id(),p.id()),result);}public void kick(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.kick(c.record().id(),p.id(),target),result);}public void disband(Player player,UUID registrarId,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.disband(c.record().id(),p.id()),result);}
public Optional<CharacterParty> party(Player player,UUID registrar){return context(player,registrar).flatMap(value->parties.partyOf(value.record().id()));}public List<CharacterPartyDirectory.Offer> invitations(Player player,UUID registrar){return context(player,registrar).map(value->parties.invitationsFor(value.record().id())).orElse(List.of());}public List<CharacterPartyDirectory.Offer> outgoingInvitations(Player player,UUID registrar){var context=context(player,registrar);if(context.isEmpty())return List.of();var party=parties.partyOf(context.orElseThrow().record().id()).filter(value->value.leader().equals(context.orElseThrow().record().id())).orElse(null);return party==null?List.of():party.invitations().stream().map(value->new CharacterPartyDirectory.Offer(value.id(),party.id(),value.character())).toList();}
private void mutate(Player player,UUID id,java.util.function.Function<Context,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}try{action.apply(context.orElseThrow()).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
private void mutateParty(Player player,UUID id,java.util.function.BiFunction<Context,CharacterParty,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}try{action.apply(context.orElseThrow(),party).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
private void complete(CharacterPartyDirectory.Result value,Throwable failure,Consumer<Outcome> result){if(failure!=null){fail(failure,result);return;}publish(()->result.accept(new Outcome(value.status()==CharacterPartyDirectory.Status.APPLIED?Status.APPLIED:map(value.status()),value.party())));}
public Optional<CharacterRecord> active(Player player,UUID registrar){return context(player,registrar).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID registrar){return context(player,registrar).map(value->value.active().session());}public boolean eligibleInvitationTarget(UUID character){return parties.partyOf(character).isEmpty()&&sessions.activeCharacter(character).filter(sessions::current).flatMap(sessions::currentRecord).filter(value->!value.dead()).isPresent();}public Optional<CharacterRecord> activeCharacter(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);return active==null?Optional.empty():sessions.currentRecord(active).filter(value->!value.dead());}
private Optional<Context> context(Player player,UUID registrarId){if(!parties.ready()||!recovered)return Optional.empty();var registrar=registrars.find(registrarId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(registrar==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(registrar.world()))return Optional.empty();double dx=location.getX()-registrar.x(),dy=location.getY()-registrar.y(),dz=location.getZ()-registrar.z();if(dx*dx+dy*dy+dz*dz>36)return Optional.empty();return Optional.of(new Context(active,record,registrar));}
private void fail(Throwable failure,Consumer<Outcome> result){report(failure);publish(()->result.accept(new Outcome(Status.FAILED,Optional.empty())));}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}private void publish(Runnable action){if(closed)return;try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
public java.util.concurrent.CompletionStage<Void> ready(){return recovery.minimalCompletionStage();}
public java.util.concurrent.CompletionStage<Void> drained(){return operations.drained();}
@Override public void close(){closed=true;operations.close();}
}
@@ -0,0 +1,28 @@
package games.dmg.fantasystick.party;
import java.text.Normalizer;
import java.util.Locale;
import java.util.Optional;
/** Validated display and globally comparable identities for persistent parties. */
public final class PartyNames {
public record Name(String display, String canonical) { }
private PartyNames() { }
public static Optional<Name> parse(String input) {
if (input == null) return Optional.empty();
String display = Normalizer.normalize(input.strip(), Normalizer.Form.NFC);
int length = display.codePointCount(0, display.length());
if (length < 1 || length > 32 || display.codePoints().anyMatch(PartyNames::forbidden)) {
return Optional.empty();
}
String canonical = Normalizer.normalize(display.toLowerCase(Locale.ROOT), Normalizer.Form.NFC);
return Optional.of(new Name(display, canonical));
}
private static boolean forbidden(int codePoint) {
int type = Character.getType(codePoint);
return type == Character.CONTROL || type == Character.FORMAT || type == Character.SURROGATE;
}
}
@@ -0,0 +1,19 @@
package games.dmg.fantasystick.party;
import java.util.Objects;
import java.util.UUID;
/** Durable desired location for one administrator-managed Party Registrar. */
public record PartyRegistrar(UUID id, String name, UUID world, double x, double y, double z, float yaw, float pitch) {
public PartyRegistrar {
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(world);
name = name.strip();
if (name.codePointCount(0, name.length()) > 64 || name.codePoints().anyMatch(codePoint -> {
int type = Character.getType(codePoint);
return type == Character.CONTROL || type == Character.FORMAT || type == Character.SURROGATE;
}) || !Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
throw new IllegalArgumentException("Invalid Party Registrar");
}
}
}
@@ -0,0 +1,46 @@
package games.dmg.fantasystick.party;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BiConsumer;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Villager;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
/** Loaded-chunk reconciliation and native protection for distinct Party Registrar villagers. */
public final class PartyRegistrarController implements Listener, AutoCloseable {
private final Plugin plugin; private final PartyRegistrarDirectory directory;
private final BiConsumer<Player,UUID> interactions; private final NamespacedKey idKey,typeKey;
private final java.util.Set<Entity> relocating=java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
private boolean closed;
public PartyRegistrarController(Plugin plugin,PartyRegistrarDirectory directory,BiConsumer<Player,UUID> interactions){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.interactions=Objects.requireNonNull(interactions);idKey=new NamespacedKey(plugin,"party_registrar_id");typeKey=new NamespacedKey(plugin,"managed_npc_type");}
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}}
public boolean tagged(Entity entity){return "party_registrar".equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,PartyRegistrar>();for(var value:directory.all())if(value.world().equals(chunk.getWorld().getUID())&&chunk(value.x())==chunk.getX()&&chunk(value.z())==chunk.getZ())desired.put(value.id(),value);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(!tagged(entity))continue;UUID id=id(entity);var target=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||target==null||!retained.add(id)){entity.remove();continue;}configure(villager,target);var current=villager.getLocation();var expected=location(target);if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawn(target);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawn(value);}
public void removeLoaded(UUID id){for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity)&&id.equals(id(entity)))entity.remove();}}
private void spawn(PartyRegistrar value){var world=plugin.getServer().getWorld(value.world());if(world==null)return;int x=chunk(value.x()),z=chunk(value.z());if(!world.isChunkLoaded(x,z))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
private void configure(Villager villager,PartyRegistrar value){villager.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,"party_registrar");villager.getPersistentDataContainer().set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.name().isBlank()?"Party Registrar":value.name()));villager.setCustomNameVisible(true);}
private Location location(PartyRegistrar value){var world=plugin.getServer().getWorld(value.world());return new Location(world,value.x(),value.y(),value.z(),value.yaw(),value.pitch());}
private UUID id(Entity entity){String value=entity.getPersistentDataContainer().get(idKey,PersistentDataType.STRING);try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
private static int chunk(double coordinate){return ((int)Math.floor(coordinate))>>4;}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(org.bukkit.event.player.PlayerInteractEntityEvent event){if(!tagged(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=org.bukkit.inventory.EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked());if(id!=null&&directory.find(id).isPresent())interactions.accept(event.getPlayer(),id);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(org.bukkit.event.entity.EntityDamageEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void combust(org.bukkit.event.entity.EntityCombustEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void transform(org.bukkit.event.entity.EntityTransformEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void breed(org.bukkit.event.entity.EntityBreedEvent event){if(tagged(event.getMother())||tagged(event.getFather()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event){if(tagged(event.getEntered()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(org.bukkit.event.entity.PlayerLeashEntityEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(org.bukkit.event.entity.EntityTeleportEvent event){if(tagged(event.getEntity())&&!relocating.contains(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.MONITOR)public void chunk(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
@Override public void close(){closed=true;for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity))entity.remove();}}
}
@@ -0,0 +1,29 @@
package games.dmg.fantasystick.party;
import games.dmg.fantasystick.persistence.YamlPartyRegistrarRepository;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/** Serialized durable desired state for Party Registrar entities. */
public final class PartyRegistrarDirectory implements AutoCloseable {
private final YamlPartyRegistrarRepository repository;
private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-party-registrar-persistence"));
private volatile List<PartyRegistrar> snapshot; private volatile boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public PartyRegistrarDirectory(YamlPartyRegistrarRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}
public List<PartyRegistrar> all(){return snapshot;}
public Optional<PartyRegistrar> find(UUID id){return snapshot.stream().filter(value->value.id().equals(id)).findFirst();}
public CompletableFuture<PartyRegistrar> place(String name,UUID world,double x,double y,double z,float yaw,float pitch){return submit(()->{var value=new PartyRegistrar(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;});}
public CompletableFuture<Optional<PartyRegistrar>> remove(UUID id){return submit(()->{var found=find(id);if(found.isEmpty())return Optional.empty();var next=snapshot.stream().filter(value->!value.id().equals(id)).toList();repository.save(next);snapshot=next;return found;});}
public CompletableFuture<PartyRegistrar> restore(PartyRegistrar registrar){return submit(()->{var found=find(registrar.id());if(found.isPresent()){if(found.orElseThrow().equals(registrar))return registrar;throw new IOException("Registrar identity collision");}var next=new ArrayList<>(snapshot);next.add(registrar);next.sort(java.util.Comparator.comparing(value->value.id().toString()));repository.save(next);snapshot=List.copyOf(next);return registrar;});}
private <T> CompletableFuture<T> submit(Callable<T> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Registrar directory closed"));return future;}pending++;}worker.execute(()->{try{future.complete(operation.call());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public java.util.concurrent.CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -0,0 +1,34 @@
package games.dmg.fantasystick.party;
import java.util.*;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
/** Plugin-owned bounded paginated Registrar menus; no chat or remote book mutation path. */
public final class PartyRegistrarMenus implements Listener,AutoCloseable {
private enum Screen{ROOT,NAME,INVITATIONS,OUTGOING,INVITE,MEMBERS}
private record Holder(UUID registrar,UUID character,UUID session,Screen screen,int page) implements InventoryHolder{public Inventory getInventory(){return null;}}
private final Server server;private final PartyManagement management;private final NamespacedKey actionKey,targetKey;private boolean closed;
public PartyRegistrarMenus(Plugin plugin,PartyManagement management){server=plugin.getServer();this.management=Objects.requireNonNull(management);actionKey=new NamespacedKey(plugin,"party_registrar_action");targetKey=new NamespacedKey(plugin,"party_registrar_target");}
public void open(Player player,UUID registrar){open(player,registrar,Screen.ROOT,0);}
private void open(Player player,UUID registrar,Screen screen,int page){if(closed)return;var record=management.active(player,registrar);if(record.isEmpty()){player.sendMessage("You must be actively playing a living fantasy character beside this Party Registrar.");return;}if(screen==Screen.NAME){openName(player,registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow());return;}var inventory=server.createInventory(new Holder(registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow(),screen,page),screen==Screen.ROOT?27:54,net.kyori.adventure.text.Component.text("Party Registrar"));var party=management.party(player,registrar);if(screen==Screen.ROOT){if(party.isEmpty()){inventory.setItem(11,item(Material.WRITABLE_BOOK,"Register a party","register",null));inventory.setItem(15,item(Material.MAP,"Invitations","invitations",null));}else{var group=party.orElseThrow();inventory.setItem(4,item(Material.PAPER,"Party: "+group.displayName(),"info",null));if(group.leader().equals(record.orElseThrow().id())){inventory.setItem(10,item(Material.EMERALD,"Invite active characters","invite",null));inventory.setItem(13,item(Material.PLAYER_HEAD,"Manage members","members",null));inventory.setItem(16,item(Material.BARRIER,"Disband party","disband",null));inventory.setItem(22,item(Material.MAP,"Pending invitations","outgoing",null));}else inventory.setItem(13,item(Material.BARRIER,"Leave party","leave",null));}}else if(screen==Screen.INVITATIONS){var values=management.invitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Invitation (left accept, right decline)","invitation",value.id()));}else if(screen==Screen.OUTGOING){var values=management.outgoingInvitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Cancel invitation for "+value.character(),"cancel-invitation",value.id()));}else if(screen==Screen.INVITE){var values=server.getOnlinePlayers().stream().filter(Player::isOnline).map(target->management.activeCharacter(target).orElse(null)).filter(Objects::nonNull).filter(target->management.eligibleInvitationTarget(target.id())).map(value->value.id()).distinct().sorted().toList();fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Invite "+value,"invite-target",value));}else{var values=party.map(value->value.members().stream().filter(member->!member.equals(value.leader())).sorted().toList()).orElse(List.of());fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Member "+value+" (left kick, right transfer)","member",value));}player.openInventory(inventory);}
private <T> void fill(Inventory inventory,int page,List<T> values,java.util.function.Function<T,ItemStack> item){int start=Math.max(0,page)*45;for(int index=start;index<Math.min(values.size(),start+45);index++)inventory.setItem(index-start,item.apply(values.get(index)));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","previous",null));if(start+45<values.size())inventory.setItem(53,item(Material.ARROW,"Next","next",null));}
private void openName(Player player,UUID registrar,UUID character,UUID session){var inventory=server.createInventory(new Holder(registrar,character,session,Screen.NAME,0),org.bukkit.event.inventory.InventoryType.ANVIL,net.kyori.adventure.text.Component.text("Choose party name"));inventory.setItem(0,item(Material.PAPER,"Party name","name",null));player.openInventory(inventory);}
private ItemStack item(Material material,String name,String action,UUID target){var item=new ItemStack(material);var meta=item.getItemMeta();meta.customName(net.kyori.adventure.text.Component.text(name));meta.getPersistentDataContainer().set(actionKey,PersistentDataType.STRING,action);if(target!=null)meta.getPersistentDataContainer().set(targetKey,PersistentDataType.STRING,target.toString());item.setItemMeta(meta);return item;}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder)||!(event.getWhoClicked() instanceof Player player))return;event.setCancelled(true);if(closed)return;if(!management.active(player,holder.registrar()).map(value->value.id().equals(holder.character())).orElse(false)||!management.activeSession(player,holder.registrar()).map(holder.session()::equals).orElse(false)){player.sendMessage("Party action refused: the character or session changed.");return;}if(holder.screen()==Screen.NAME&&event.getRawSlot()==2){String name=event.getView() instanceof org.bukkit.inventory.view.AnvilView anvil?anvil.getRenameText():null;if(name!=null)management.register(player,holder.registrar(),name,outcome->respond(player,holder.registrar(),outcome,"Registered party."));return;}var clicked=event.getCurrentItem();if(clicked==null)return;var data=clicked.getItemMeta().getPersistentDataContainer();String action=data.get(actionKey,PersistentDataType.STRING);UUID target=uuid(data.get(targetKey,PersistentDataType.STRING));if("register".equals(action)){open(player,holder.registrar(),Screen.NAME,0);return;}if("invitations".equals(action)){open(player,holder.registrar(),Screen.INVITATIONS,0);return;}if("invite".equals(action)){open(player,holder.registrar(),Screen.INVITE,0);return;}if("outgoing".equals(action)){open(player,holder.registrar(),Screen.OUTGOING,0);return;}if("members".equals(action)){open(player,holder.registrar(),Screen.MEMBERS,0);return;}if("previous".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()-1);return;}if("next".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()+1);return;}if("leave".equals(action)){management.leave(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Left party."));return;}if("disband".equals(action)){management.disband(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Party ended."));return;}if(target==null)return;if("invite-target".equals(action)){management.invite(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation saved."));return;}if("cancel-invitation".equals(action)){management.cancel(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation cancelled."));return;}if("invitation".equals(action)){if(event.isRightClick())management.decline(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation declined."));else management.accept(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Joined party."));return;}if("member".equals(action)){if(event.isRightClick())management.transfer(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Leadership transferred."));else management.kick(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Member removed."));}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void drag(org.bukkit.event.inventory.InventoryDragEvent event){if(event.getInventory().getHolder() instanceof Holder)event.setCancelled(true);}
private void respond(Player player,UUID registrar,PartyManagement.Outcome outcome,String success){if(!player.isOnline())return;if(outcome.status()==PartyManagement.Status.APPLIED){player.sendMessage(success);open(player,registrar);}else player.sendMessage("Party action refused: "+outcome.status().name().toLowerCase(Locale.ROOT).replace('_',' ')+".");}
private static UUID uuid(String value){try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
@Override public void close(){closed=true;}
}
@@ -1,131 +1,39 @@
package games.dmg.fantasystick.party;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.*;
import java.util.function.Predicate;
/** Server-thread-confined, player-owned parties. No state survives a plugin/server restart. */
/** Party facade: durable character identity in production, retained transient constructor for isolated legacy tests. */
public final class PartyRoster implements AutoCloseable {
public record Party(UUID id, UUID leader, List<UUID> members) {
public Party { members = List.copyOf(members); }
}
public record Invitation(UUID id, UUID party, UUID leader, UUID invitee, Instant expires) { }
private final Map<UUID, Party> membership = new HashMap<>();
private final Map<UUID, Invitation> invitations = new HashMap<>();
private final Set<UUID> departed = new HashSet<>();
private boolean closed;
private final Clock clock;
private final Predicate<UUID> online;
private final java.util.function.IntSupplier maximumSize;
public PartyRoster(Clock clock, Predicate<UUID> online) { this(clock, online, () -> 6); }
public PartyRoster(Clock clock, Predicate<UUID> online, java.util.function.IntSupplier maximumSize) {
this.clock = Objects.requireNonNull(clock);
this.online = Objects.requireNonNull(online);
this.maximumSize = Objects.requireNonNull(maximumSize);
maximumSize();
}
public int maximumSize() {
int value = maximumSize.getAsInt();
if (value < 1) throw new IllegalStateException("Party maximum must be positive");
return value;
}
public Optional<Party> create(UUID player) {
if (closed || !connected(player) || membership.containsKey(player)) return Optional.empty();
var party = new Party(UUID.randomUUID(), player, List.of(player));
membership.put(player, party);
return Optional.of(party);
}
public Optional<Party> partyOf(UUID player) { return Optional.ofNullable(membership.get(player)); }
public List<Invitation> invitationsFor(UUID player) {
expire();
return invitations.values().stream().filter(i -> i.invitee().equals(player))
.sorted(java.util.Comparator.comparing(Invitation::expires).thenComparing(Invitation::id)).toList();
}
public Optional<Invitation> invite(UUID leader, UUID party, UUID invitee) {
expire();
var group = managedBy(leader, party);
if (group == null || group.members().size() >= maximumSize() || !connected(invitee) || membership.containsKey(invitee)) return Optional.empty();
var existing = invitations.values().stream().filter(i -> i.party().equals(party) && i.invitee().equals(invitee)).findFirst();
if (existing.isPresent()) return existing;
var invitation = new Invitation(UUID.randomUUID(), party, leader, invitee, clock.instant().plusSeconds(60));
invitations.put(invitation.id(), invitation);
return Optional.of(invitation);
}
public boolean accept(UUID player, UUID invitation) {
expire();
var offer = invitations.get(invitation);
if (offer == null || !offer.invitee().equals(player) || !connected(player) || membership.containsKey(player)) return false;
var group = membership.get(offer.leader());
if (group == null || group.members().size() >= maximumSize() || !group.id().equals(offer.party())) return false;
var members = new ArrayList<>(group.members());
members.add(player);
var updated = new Party(group.id(), group.leader(), members);
for (var member : members) membership.put(member, updated);
invitations.values().removeIf(i -> i.invitee().equals(player));
return true;
}
public boolean kick(UUID leader, UUID party, UUID member) {
var group = managedBy(leader, party);
if (group == null || !group.members().contains(member) || member.equals(leader)) return false;
removeMember(group, member);
return true;
}
public boolean disband(UUID leader, UUID party) {
var group = managedBy(leader, party);
if (group == null) return false;
remove(group);
return true;
}
private Party managedBy(UUID leader, UUID party) {
var group = membership.get(leader);
return group != null && group.id().equals(party) && group.leader().equals(leader) && connected(leader) ? group : null;
}
public boolean leave(UUID player, UUID party) {
var group = membership.get(player);
if (group == null || !group.id().equals(party) || !connected(player)) return false;
if (group.leader().equals(player)) remove(group);
else removeMember(group, player);
return true;
}
private void removeMember(Party group, UUID player) {
var remaining = group.members().stream().filter(member -> !member.equals(player)).toList();
if (remaining.stream().noneMatch(this::connected)) { remove(group); return; }
membership.remove(player);
departed.remove(player);
var updated = new Party(group.id(), group.leader(), remaining);
for (var member : remaining) membership.put(member, updated);
}
public void arrive(UUID player) { departed.remove(player); }
public void disconnect(UUID player) {
var group = membership.get(player);
if (group == null) return;
departed.add(player);
if (group.members().stream().noneMatch(this::connected)) remove(group);
}
private boolean connected(UUID player) { return !departed.contains(player) && online.test(player); }
private void expire() {
var now = clock.instant();
invitations.values().removeIf(i -> !now.isBefore(i.expires()));
}
private void remove(Party group) {
for (var member : group.members()) membership.remove(member);
departed.removeAll(group.members());
invitations.values().removeIf(i -> i.party().equals(group.id()));
}
@Override public void close() {
closed = true;
membership.clear();
invitations.clear();
departed.clear();
}
public record Party(UUID id,UUID leader,List<UUID> members,UUID leaderCharacter,List<UUID> characters){public Party{members=List.copyOf(members);characters=List.copyOf(characters);}public Party(UUID id,UUID leader,List<UUID> members){this(id,leader,members,leader,members);}}
public record Invitation(UUID id,UUID party,UUID leader,UUID invitee,Instant expires){}
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<>();
private final Clock clock;private final Predicate<UUID> online;private final java.util.function.IntSupplier maximumSize;
private final CharacterPartyDirectory durable;private final EquipmentSessions sessions;private boolean closed;
public PartyRoster(Clock clock,Predicate<UUID> online){this(clock,online,()->6);}
public PartyRoster(Clock clock,Predicate<UUID> online,java.util.function.IntSupplier maximumSize){this.clock=Objects.requireNonNull(clock);this.online=Objects.requireNonNull(online);this.maximumSize=Objects.requireNonNull(maximumSize);durable=null;sessions=null;maximumSize();}
public PartyRoster(CharacterPartyDirectory durable,EquipmentSessions sessions,java.util.function.IntSupplier maximumSize){this.durable=Objects.requireNonNull(durable);this.sessions=Objects.requireNonNull(sessions);this.maximumSize=Objects.requireNonNull(maximumSize);clock=Clock.systemUTC();online=id->true;maximumSize();}
public int maximumSize(){int value=maximumSize.getAsInt();if(value<1)throw new IllegalStateException("Party maximum must be positive");return value;}
public Optional<Party> create(UUID player){if(closed)return Optional.empty();if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);if(character==null)return Optional.empty();var result=durable.create(character,"Party "+character.toString().substring(0,8)).join();return result.party().flatMap(this::project);}if(closed||!connected(player)||membership.containsKey(player))return Optional.empty();var party=new Party(UUID.randomUUID(),player,List.of(player));membership.put(player,party);return Optional.of(party);}
public Optional<Party> partyOf(UUID player){if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);return character==null?Optional.empty():durable.partyOf(character).flatMap(this::project);}return Optional.ofNullable(membership.get(player));}
public Optional<RewardMembership> rewardMembership(UUID player){if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);if(character==null)return Optional.empty();return durable.partyOf(character).map(party->new RewardMembership(party.id(),activePlayers(party)));}var party=membership.get(player);return party==null?Optional.empty():Optional.of(new RewardMembership(party.id(),party.members().stream().filter(this::connected).toList()));}
public List<Invitation> invitationsFor(UUID player){if(durable!=null){if(!durable.ready())return List.of();var character=character(player);if(character==null)return List.of();return durable.invitationsFor(character).stream().map(value->new Invitation(value.id(),value.party(),new UUID(0,0),player,Instant.MAX)).toList();}expire();return invitations.values().stream().filter(i->i.invitee().equals(player)).sorted(Comparator.comparing(Invitation::expires).thenComparing(Invitation::id)).toList();}
public Optional<Invitation> invite(UUID leader,UUID party,UUID invitee){if(closed)return Optional.empty();if(durable!=null){if(!durable.ready())return Optional.empty();var source=character(leader);var target=character(invitee);if(source==null||target==null)return Optional.empty();var result=durable.invite(source,party,target).join();return result.invitation().map(value->new Invitation(value.id(),value.party(),leader,invitee,Instant.MAX));}expire();var group=managedBy(leader,party);if(group==null||group.members().size()>=maximumSize()||!connected(invitee)||membership.containsKey(invitee))return Optional.empty();var existing=invitations.values().stream().filter(i->i.party().equals(party)&&i.invitee().equals(invitee)).findFirst();if(existing.isPresent())return existing;var invitation=new Invitation(UUID.randomUUID(),party,leader,invitee,clock.instant().plusSeconds(60));invitations.put(invitation.id(),invitation);return Optional.of(invitation);}
public boolean accept(UUID player,UUID invitation){if(closed)return false;if(durable!=null){if(!durable.ready())return false;var character=character(player);return character!=null&&durable.accept(character,invitation).join().status()==CharacterPartyDirectory.Status.APPLIED;}expire();var offer=invitations.get(invitation);if(offer==null||!offer.invitee().equals(player)||!connected(player)||membership.containsKey(player))return false;var group=membership.get(offer.leader());if(group==null||group.members().size()>=maximumSize()||!group.id().equals(offer.party()))return false;var members=new ArrayList<>(group.members());members.add(player);var updated=new Party(group.id(),group.leader(),members);for(var member:members)membership.put(member,updated);invitations.values().removeIf(i->i.invitee().equals(player));return true;}
public boolean kick(UUID leader,UUID party,UUID member){if(closed)return false;if(durable!=null){if(!durable.ready())return false;var a=character(leader);var b=character(member);return a!=null&&b!=null&&durable.kick(a,party,b).join().status()==CharacterPartyDirectory.Status.APPLIED;}var group=managedBy(leader,party);if(group==null||!group.members().contains(member)||member.equals(leader))return false;removeMember(group,member);return true;}
public boolean disband(UUID leader,UUID party){if(closed)return false;if(durable!=null){if(!durable.ready())return false;var character=character(leader);return character!=null&&durable.disband(character,party).join().status()==CharacterPartyDirectory.Status.APPLIED;}var group=managedBy(leader,party);if(group==null)return false;remove(group);return true;}
public boolean leave(UUID player,UUID party){if(closed)return false;if(durable!=null){if(!durable.ready())return false;var character=character(player);return character!=null&&durable.leave(character,party).join().status()==CharacterPartyDirectory.Status.APPLIED;}var group=membership.get(player);if(group==null||!group.id().equals(party)||!connected(player))return false;if(group.leader().equals(player))remove(group);else removeMember(group,player);return true;}
private UUID character(UUID player){return sessions.active(player).filter(sessions::current).map(EquipmentSessions.Active::character).orElse(null);}
private List<UUID> activePlayers(CharacterParty party){return party.members().stream().map(sessions::activeCharacter).flatMap(Optional::stream).filter(sessions::current).map(EquipmentSessions.Active::player).sorted().toList();}
public Optional<UUID> activePlayer(UUID character){if(durable==null)return Optional.of(character);return sessions.activeCharacter(character).filter(sessions::current).map(EquipmentSessions.Active::player);}
private Optional<Party> project(CharacterParty party){var members=activePlayers(party);UUID leader=sessions.activeCharacter(party.leader()).filter(sessions::current).map(EquipmentSessions.Active::player).orElse(party.leader());return Optional.of(new Party(party.id(),leader,members,party.leader(),party.members().stream().sorted().toList()));}
private Party managedBy(UUID leader,UUID party){var group=membership.get(leader);return group!=null&&group.id().equals(party)&&group.leader().equals(leader)&&connected(leader)?group:null;}
private void removeMember(Party group,UUID player){var remaining=group.members().stream().filter(member->!member.equals(player)).toList();if(remaining.stream().noneMatch(this::connected)){remove(group);return;}membership.remove(player);departed.remove(player);var updated=new Party(group.id(),group.leader(),remaining);for(var member:remaining)membership.put(member,updated);}
public void arrive(UUID player){if(durable==null)departed.remove(player);}public void disconnect(UUID player){if(durable!=null)return;var group=membership.get(player);if(group==null)return;departed.add(player);if(group.members().stream().noneMatch(this::connected))remove(group);}
private boolean connected(UUID player){return!departed.contains(player)&&online.test(player);}private void expire(){var now=clock.instant();invitations.values().removeIf(i->!now.isBefore(i.expires()));}private void remove(Party group){for(var member:group.members())membership.remove(member);departed.removeAll(group.members());invitations.values().removeIf(i->i.party().equals(group.id()));}
@Override public void close(){closed=true;membership.clear();invitations.clear();departed.clear();}
}
@@ -0,0 +1,11 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.shop.ArmorCatalog;
import java.io.IOException;
import java.util.Optional;
/** Durable adapter boundary for the one shared armor catalog. */
public interface ArmorCatalogRepository {
Optional<ArmorCatalog> load() throws IOException;
void save(ArmorCatalog catalog) throws IOException;
}
@@ -0,0 +1,84 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.shop.ArmorProduct;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
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 recovery plans spanning one catalog reservation and character gear purchase. */
public final class ArmorPurchaseOperationStore implements AutoCloseable {
public record Plan(UUID id, UUID character, BigInteger expectedCharacterRevision, UUID catalogRevision,
ArmorProduct product, BigInteger price) {
public Plan {
Objects.requireNonNull(id); Objects.requireNonNull(character); Objects.requireNonNull(expectedCharacterRevision);
Objects.requireNonNull(catalogRevision); Objects.requireNonNull(product); Objects.requireNonNull(price);
if(expectedCharacterRevision.signum()<0||price.signum()<=0)throw new IllegalArgumentException("Invalid armor purchase plan");
}
}
private final Path directory;
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-armor-operation-persistence"));
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained=new CompletableFuture<>();
public ArmorPurchaseOperationStore(Path data){directory=data.toAbsolutePath().resolve("armor-purchase-operations");}
public CompletionStage<Plan> prepare(Plan plan){return submit(()->{
Files.createDirectories(directory);Path target=file(plan.id());
if(Files.exists(target)) {
var existing=decode(Files.readString(target,StandardCharsets.UTF_8));
if(!existing.equals(plan))throw new IOException("Armor operation identity collision");
return existing;
}
write(target,encode(plan));return plan;
});}
public CompletionStage<Void> complete(UUID id){return submit(()->{if(Files.deleteIfExists(file(id)))DurableFiles.forceDirectory(directory);return null;});}
public CompletionStage<List<Plan>> load(){return submit(this::loadBlocking);}
private List<Plan> loadBlocking()throws IOException {
if(Files.notExists(directory))return List.of();
var result=new ArrayList<Plan>();
try(var files=Files.list(directory)) {
for(var path:files.filter(value->value.getFileName().toString().endsWith(".yml")).sorted().toList()) {
if(Files.size(path)>65_536)throw new IOException("Armor operation too large");
result.add(decode(Files.readString(path,StandardCharsets.UTF_8)));
}
}
return List.copyOf(result);
}
private Path file(UUID id){return directory.resolve(id+".yml");}
private static String encode(Plan plan){Map<String,Object> row=new LinkedHashMap<>();row.put("schema-version",1);
row.put("operation-id",plan.id().toString());row.put("character",plan.character().toString());
row.put("expected-character-revision",plan.expectedCharacterRevision().toString());
row.put("catalog-revision",plan.catalogRevision().toString());row.put("slot",plan.product().slot().name());
row.put("tier",plan.product().tier().name());row.put("price",plan.price().toString());return yaml().dump(row);}
private static Plan decode(String input){Object decoded=yaml().load(input);if(!(decoded instanceof Map<?,?> row)||
!"1".equals(String.valueOf(row.get("schema-version"))))throw new IllegalArgumentException("Invalid armor operation schema");
return new Plan(UUID.fromString(text(row,"operation-id")),UUID.fromString(text(row,"character")),
new BigInteger(text(row,"expected-character-revision")),UUID.fromString(text(row,"catalog-revision")),
new ArmorProduct(GearSlot.valueOf(text(row,"slot")),GearTier.valueOf(text(row,"tier"))),new BigInteger(text(row,"price")));}
private static void write(Path target,String content)throws IOException {Path temporary=Files.createTempFile(target.getParent(),"armor-operation-",".tmp");
try{byte[] bytes=content.getBytes(StandardCharsets.UTF_8);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){
var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}
try{Files.move(temporary,target,StandardCopyOption.ATOMIC_MOVE);}catch(AtomicMoveNotSupportedException unsupported){
throw new IOException("Atomic armor-operation persistence unavailable",unsupported);}DurableFiles.forceDirectory(target.getParent());}
finally{Files.deleteIfExists(temporary);}}
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);
options.setCodePointLimit(65_536);return new Yaml(new SafeConstructor(options));}
private static String text(Map<?,?> row,String key){Object value=row.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException("Invalid "+key);}
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(
new IOException("Armor operation store closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}
catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}
public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -0,0 +1,11 @@
package games.dmg.fantasystick.persistence;
import java.util.Objects;
/** Permanent helmet-free character armor state. */
public record CharacterGear(GearTier chestplate,GearTier leggings,GearTier boots) {
public CharacterGear{if(chestplate==null||leggings==null||boots==null)throw new IllegalArgumentException("Missing gear tier");}
public static CharacterGear none(){return new CharacterGear(GearTier.NONE,GearTier.NONE,GearTier.NONE);}
public GearTier tier(GearSlot slot){return switch(slot){case CHESTPLATE->chestplate;case LEGGINGS->leggings;case BOOTS->boots;};}
public CharacterGear with(GearSlot slot,GearTier tier){Objects.requireNonNull(slot);Objects.requireNonNull(tier);return switch(slot){case CHESTPLATE->new CharacterGear(tier,leggings,boots);case LEGGINGS->new CharacterGear(chestplate,tier,boots);case BOOTS->new CharacterGear(chestplate,leggings,tier);};}
}
@@ -18,13 +18,23 @@ 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,CharacterGear gear) {
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,BigInteger pvpCoinEscrow){this(id,name,revision,stats,level,experience,unspentPoints,health,learned,loadout,passives,cooldowns,restrictedKills,dead,deathId,diedAt,coins,coinsInitialized,rewardReceipts,pvpCoinEscrow,CharacterGear.none());}
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 +43,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 +62,16 @@ public record CharacterRecord(
Objects.requireNonNull(experience);
Objects.requireNonNull(unspentPoints);
Objects.requireNonNull(health);
Objects.requireNonNull(coins);
Objects.requireNonNull(pvpCoinEscrow);
Objects.requireNonNull(gear);
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 +85,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.gear().equals(CharacterGear.none())
|| 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(),old.pvpCoinEscrow(),old.gear());
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(),old.pvpCoinEscrow(),old.gear());
repository.replace(renamed, old.revision());
return Optional.of(renamed);
});
@@ -247,29 +271,90 @@ 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(),old.pvpCoinEscrow(),old.gear());
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 CoinSpendStatus { APPLIED, DUPLICATE, INSUFFICIENT, STALE, UNAVAILABLE }
public record CoinSpend(CoinSpendStatus status, Optional<CharacterRecord> record) { }
/** Persist one operation-keyed debit; an acknowledged operation can never charge twice. */
public CompletableFuture<CoinSpend> spendCoins(UUID characterId, BigInteger expectedRevision, UUID operationId,
BigInteger amount) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision);
Objects.requireNonNull(operationId); Objects.requireNonNull(amount);
if (amount.signum() < 0) throw new IllegalArgumentException("Coin debit must not be negative");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new CoinSpend(CoinSpendStatus.UNAVAILABLE, Optional.empty());
}
var old = found.orElseThrow();
if (old.rewardReceipts().contains(operationId)) {
return new CoinSpend(CoinSpendStatus.DUPLICATE, Optional.of(old));
}
if (!old.revision().equals(expectedRevision)) {
return new CoinSpend(CoinSpendStatus.STALE, Optional.of(old));
}
if (old.coins().compareTo(amount) < 0) {
return new CoinSpend(CoinSpendStatus.INSUFFICIENT, Optional.of(old));
}
var receipts = new java.util.HashSet<>(old.rewardReceipts());
if (receipts.size() >= 4096) receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());
receipts.add(operationId);
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(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(),
old.coins().subtract(amount), true, receipts,old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new CoinSpend(CoinSpendStatus.APPLIED, Optional.of(saved));
});
}
public enum CoinRefundStatus { APPLIED, DUPLICATE, UNAVAILABLE }
public record CoinRefund(CoinRefundStatus status,Optional<CharacterRecord> record){}
/** Compensate a persisted debit exactly once while retaining both audit receipts. */
public CompletableFuture<CoinRefund> refundCoins(UUID characterId,UUID debitId,UUID refundId,BigInteger amount){Objects.requireNonNull(characterId);Objects.requireNonNull(debitId);Objects.requireNonNull(refundId);Objects.requireNonNull(amount);if(amount.signum()<0)throw new IllegalArgumentException("Coin refund must not be negative");return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(refundId))return new CoinRefund(CoinRefundStatus.DUPLICATE,Optional.of(old));if(!old.rewardReceipts().contains(debitId))return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().filter(id->!id.equals(debitId)).min(UUID::compareTo).orElseThrow());receipts.add(refundId);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(),old.cooldowns(),old.restrictedKills(),old.dead(),old.deathId(),old.diedAt(),old.coins().add(amount),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear());repository.replace(saved,old.revision());return new CoinRefund(CoinRefundStatus.APPLIED,Optional.of(saved));});}
public enum GearPurchaseStatus { APPLIED,DUPLICATE,INSUFFICIENT,NOT_UPGRADE,STALE,UNAVAILABLE }
public record GearPurchase(GearPurchaseStatus status,Optional<CharacterRecord> record){}
/** One character-file transaction owns both the exact coin debit and strict gear replacement. */
public CompletableFuture<GearPurchase> purchaseGear(UUID characterId,BigInteger expectedRevision,UUID operationId,GearSlot slot,GearTier target,BigInteger price){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(slot);Objects.requireNonNull(target);Objects.requireNonNull(price);if(target==GearTier.NONE||price.signum()<0)throw new IllegalArgumentException("Invalid gear purchase");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new GearPurchase(GearPurchaseStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new GearPurchase(GearPurchaseStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new GearPurchase(GearPurchaseStatus.STALE,Optional.of(old));if(!target.higherThan(old.gear().tier(slot)))return new GearPurchase(GearPurchaseStatus.NOT_UPGRADE,Optional.of(old));if(old.coins().compareTo(price)<0)return new GearPurchase(GearPurchaseStatus.INSUFFICIENT,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);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(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins().subtract(price),true,receipts,old.pvpCoinEscrow(),old.gear().with(slot,target));repository.replace(saved,old.revision());return new GearPurchase(GearPurchaseStatus.APPLIED,Optional.of(saved));});}
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 +364,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,old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new CombatAward(status, Optional.of(saved), gained);
});
@@ -329,7 +416,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(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
});
@@ -357,7 +444,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(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
});
@@ -392,7 +479,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(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -412,7 +499,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(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -435,7 +522,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(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -459,7 +546,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(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -504,7 +591,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(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
});
@@ -530,7 +617,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(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return Optional.of(saved);
});
@@ -543,14 +630,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 +652,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,old.gear());
repository.replace(dead, old.revision());
return Optional.of(dead);
});
@@ -598,7 +691,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(),BigInteger.ZERO,old.gear());
repository.replace(restored, old.revision());
return Optional.of(restored);
});
@@ -54,6 +54,11 @@ 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("gear",Map.of("chestplate",record.gear().chestplate().name(),"leggings",record.gear().leggings().name(),"boots",record.gear().boots().name()));
root.put("dead", record.dead());
if (record.deathId() != null) {
root.put("death-id", record.deathId().toString());
@@ -97,9 +102,17 @@ 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,
root.containsKey("gear")?gear(root.get("gear")):CharacterGear.none());
}
private static CharacterGear gear(Object value){var gear=object(value);return new CharacterGear(GearTier.valueOf(text(gear.get("chestplate"))),GearTier.valueOf(text(gear.get("leggings"))),GearTier.valueOf(text(gear.get("boots"))));}
private static Map<?, ?> object(Object value) {
if (value instanceof Map<?, ?> map) return map;
throw new IllegalArgumentException("Expected YAML mapping");
@@ -114,6 +127,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,14 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
/** Filesystem durability boundary used after atomically replacing or deleting acknowledged state. */
final class DurableFiles {
private DurableFiles() { }
static void forceDirectory(Path directory) throws IOException {
try (var channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); }
}
}
@@ -0,0 +1,186 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.shop.ArmorMarketRules;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publication bases, variance and tier stock bounds for future catalogs. */
public final class GearSettings implements AutoCloseable {
public static final String LEATHER="gear-price-leather", COPPER="gear-price-copper",
GOLD="gear-price-gold", IRON="gear-price-iron", DIAMOND="gear-price-diamond",
NETHERITE="gear-price-netherite", VARIANCE="gear-price-variance-percent";
private static final Map<GearTier,String> PRICE_KEYS=Map.of(
GearTier.LEATHER,LEATHER, GearTier.COPPER,COPPER, GearTier.GOLD,GOLD,
GearTier.IRON,IRON, GearTier.DIAMOND,DIAMOND, GearTier.NETHERITE,NETHERITE);
private static final Map<GearTier,String> TIER_NAMES=Map.of(
GearTier.LEATHER,"leather", GearTier.COPPER,"copper", GearTier.GOLD,"gold",
GearTier.IRON,"iron", GearTier.DIAMOND,"diamond", GearTier.NETHERITE,"netherite");
private static final Map<String,String> DEFAULTS=defaults();
private static final List<String> KEYS=DEFAULTS.keySet().stream().sorted().toList();
public record Update(String key,String value,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-gear-configuration"));
private volatile Map<String,String> effective;
private Map<String,String> persisted;
private long savedSequence,publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained=new CompletableFuture<>();
public GearSettings(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.getOrDefault(key,DEFAULTS.get(key))));
validate(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 int price(GearTier tier){return wholeNonnegative(effective.get(priceKey(tier)));}
public ArmorMarketRules rules() {
var values=effective;
var prices=new EnumMap<GearTier,BigInteger>(GearTier.class);
var stock=new EnumMap<GearTier,ArmorMarketRules.StockRange>(GearTier.class);
for(var tier:PRICE_KEYS.keySet()) {
prices.put(tier,new BigInteger(values.get(priceKey(tier))));
stock.put(tier,new ArmorMarketRules.StockRange(
wholePositive(values.get(stockKey(tier,true))), wholePositive(values.get(stockKey(tier,false)))));
}
return new ArmorMarketRules(prices,wholeVariance(values.get(VARIANCE)),stock);
}
public CompletionStage<Update> set(String key,String raw) {
if(!KEYS.contains(key)) throw new IllegalArgumentException("Unknown gear market setting");
String accepted;
try { accepted=Integer.toString(parseForKey(key,raw)); }
catch(RuntimeException invalid) { throw new IllegalArgumentException("Invalid gear market setting",invalid); }
var preview=new LinkedHashMap<>(effective);
preview.put(key,accepted);
validate(preview);
synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration closed"));pending++;}
return CompletableFuture.supplyAsync(()->{
try {
var next=new LinkedHashMap<>(persisted);
next.put(key,accepted);
validate(next);
save(key,accepted);
persisted=Map.copyOf(next);
return new Update(key,accepted,persisted,++savedSequence);
} catch(IOException failure) { throw new CompletionException(failure); }
finally { finished(); }
},worker).minimalCompletionStage();
}
public synchronized boolean publish(Update update) {
if(update.sequence()<=publishedSequence)return false;
validate(update.values());
effective=update.values();
publishedSequence=update.sequence();
return true;
}
private static Map<String,String> defaults() {
var values=new LinkedHashMap<String,String>();
values.put(LEATHER,"2"); values.put(COPPER,"10"); values.put(GOLD,"25");
values.put(IRON,"75"); values.put(DIAMOND,"150"); values.put(NETHERITE,"500");
values.put(VARIANCE,"10");
int[][] ranges={{25,50},{20,40},{15,30},{10,20},{5,10},{1,3}};
GearTier[] tiers={GearTier.LEATHER,GearTier.COPPER,GearTier.GOLD,GearTier.IRON,GearTier.DIAMOND,GearTier.NETHERITE};
for(int i=0;i<tiers.length;i++) {
values.put(stockKey(tiers[i],true),Integer.toString(ranges[i][0]));
values.put(stockKey(tiers[i],false),Integer.toString(ranges[i][1]));
}
return Map.copyOf(values);
}
private static void validate(Map<String,String> values) {
for(var tier:PRICE_KEYS.keySet()) {
wholeNonnegative(values.get(priceKey(tier)));
int minimum=wholePositive(values.get(stockKey(tier,true)));
int maximum=wholePositive(values.get(stockKey(tier,false)));
if(minimum>maximum)throw new IllegalArgumentException("Stock minimum exceeds maximum");
}
wholeVariance(values.get(VARIANCE));
}
private static int parseForKey(String key,String value) {
if(key.equals(VARIANCE))return wholeVariance(value);
if(key.startsWith("gear-stock-"))return wholePositive(value);
return wholeNonnegative(value);
}
private static int wholeNonnegative(String value){return bounded(value,0,Integer.MAX_VALUE);}
private static int wholePositive(String value){return bounded(value,1,Integer.MAX_VALUE);}
private static int wholeVariance(String value){return bounded(value,0,100);}
private static int bounded(String value,int minimum,int maximum) {
var number=new BigInteger(Objects.requireNonNull(value));
if(number.compareTo(BigInteger.valueOf(minimum))<0||number.compareTo(BigInteger.valueOf(maximum))>0)
throw new IllegalArgumentException();
return number.intValueExact();
}
private static String priceKey(GearTier tier) {
var key=PRICE_KEYS.get(tier);
if(key==null)throw new IllegalArgumentException("None is not purchasable");
return key;
}
public static String stockKey(GearTier tier,boolean minimum) {
var name=TIER_NAMES.get(tier);
if(name==null)throw new IllegalArgumentException("None is not purchasable");
return "gear-stock-"+name+(minimum?"-min":"-max");
}
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,Integer.parseInt(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);
DurableFiles.forceDirectory(directory);
} finally { Files.deleteIfExists(temporary); }
} catch(OverlappingFileLockException busy) { throw new IOException("Configuration busy",busy); }
}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}
public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -0,0 +1,4 @@
package games.dmg.fantasystick.persistence;
/** Purchasable character armor slots; head is intentionally absent and remains cosmetic-only. */
public enum GearSlot { CHESTPLATE, LEGGINGS, BOOTS }
@@ -0,0 +1,7 @@
package games.dmg.fantasystick.persistence;
/** Strict reviewed armor progression; enum order is authoritative for upgrade comparisons. */
public enum GearTier {
NONE, LEATHER, COPPER, GOLD, IRON, DIAMOND, NETHERITE;
public boolean higherThan(GearTier current){return ordinal()>current.ordinal();}
}
@@ -0,0 +1,34 @@
package games.dmg.fantasystick.persistence;
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 prices for future party transactions. */
public final class PartyEconomySettings implements AutoCloseable {
public static final String REGISTRATION="party-registration-price", LEADER_CHANGE="party-leader-change-price";
private static final List<String> KEYS=List.of(LEADER_CHANGE,REGISTRATION);
private static final Map<String,String> DEFAULTS=Map.of(REGISTRATION,"10",LEADER_CHANGE,"10");
public record Update(String key,String value,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-party-economy-configuration"));
private volatile Map<String,String> effective; private Map<String,String> persisted;
private long savedSequence,publishedSequence; private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public PartyEconomySettings(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)));validate(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 int registrationPrice(){return whole(effective.get(REGISTRATION));} public int leaderChangePrice(){return whole(effective.get(LEADER_CHANGE));}
public CompletionStage<Update> set(String key,String raw){if(!KEYS.contains(key))throw new IllegalArgumentException("Unknown party price");String accepted;try{accepted=Integer.toString(whole(raw));}catch(RuntimeException invalid){throw new IllegalArgumentException("Party price must be a whole number from 0 through 2147483647",invalid);}var preview=new LinkedHashMap<>(effective);preview.put(key,accepted);validate(preview);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{var next=new LinkedHashMap<>(persisted);next.put(key,accepted);validate(next);save(key,accepted);persisted=Map.copyOf(next);return new Update(key,accepted,persisted,++savedSequence);}catch(IOException failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;validate(update.values());effective=update.values();publishedSequence=update.sequence();return true;}
private static void validate(Map<String,String> values){for(String key:KEYS)whole(values.get(key));}
private static int 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.intValueExact();}
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,Integer.parseInt(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);DurableFiles.forceDirectory(directory);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy",busy);}}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -0,0 +1,35 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.party.PartyNames;
import java.io.IOException;
import java.math.BigInteger;
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 recovery plans spanning a character debit and a party mutation. */
public final class PartyOperationStore implements AutoCloseable {
public enum Kind{CREATE,TRANSFER}
public record Plan(UUID id,Kind kind,UUID actor,BigInteger expectedRevision,BigInteger amount,String name,UUID party,UUID target,BigInteger expectedPartyRevision){public Plan{Objects.requireNonNull(id);Objects.requireNonNull(kind);Objects.requireNonNull(actor);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(amount);if(expectedRevision.signum()<0||amount.signum()<0||kind==Kind.CREATE&&(PartyNames.parse(name).isEmpty()||party!=null||target!=null||expectedPartyRevision!=null)||kind==Kind.TRANSFER&&(party==null||target==null||name!=null||expectedPartyRevision==null||expectedPartyRevision.signum()<0))throw new IllegalArgumentException("Invalid party operation");}}
private final Path directory;private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-party-operation-persistence"));private volatile boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public PartyOperationStore(Path data){directory=data.toAbsolutePath().resolve("party-operations");}
public static UUID refundId(UUID operation){return UUID.nameUUIDFromBytes(("party-refund:"+operation).getBytes(StandardCharsets.UTF_8));}
public CompletionStage<Plan> prepare(Plan plan){return submit(()->{Files.createDirectories(directory);if(plan.kind()==Kind.CREATE){String canonical=PartyNames.parse(plan.name()).orElseThrow().canonical();for(var pending:loadBlocking())if(pending.kind()==Kind.CREATE&&PartyNames.parse(pending.name()).orElseThrow().canonical().equals(canonical)&&!pending.id().equals(plan.id()))throw new IOException("Party name already reserved");}Path target=file(plan.id());if(Files.exists(target)){var existing=decode(Files.readString(target));if(!existing.equals(plan))throw new IOException("Operation identity collision");return existing;}write(target,encode(plan));return plan;});}
public CompletionStage<Void> complete(UUID id){return submit(()->{if(Files.deleteIfExists(file(id)))DurableFiles.forceDirectory(directory);return null;});}
public CompletionStage<List<Plan>> load(){return submit(this::loadBlocking);}
private List<Plan> loadBlocking()throws IOException{if(Files.notExists(directory))return List.of();var result=new ArrayList<Plan>();try(var files=Files.list(directory)){for(var path:files.filter(value->value.getFileName().toString().endsWith(".yml")).sorted().toList()){if(Files.size(path)>65536)throw new IOException("Party operation too large");result.add(decode(Files.readString(path,StandardCharsets.UTF_8)));}}return List.copyOf(result);}
private Path file(UUID id){return directory.resolve(id+".yml");}
private static String encode(Plan p){Map<String,Object> row=new LinkedHashMap<>();row.put("schema-version",1);row.put("operation-id",p.id().toString());row.put("kind",p.kind().name());row.put("actor",p.actor().toString());row.put("expected-revision",p.expectedRevision().toString());row.put("amount",p.amount().toString());if(p.name()!=null)row.put("name",p.name());if(p.party()!=null)row.put("party",p.party().toString());if(p.target()!=null)row.put("target",p.target().toString());if(p.expectedPartyRevision()!=null)row.put("expected-party-revision",p.expectedPartyRevision().toString());return yaml().dump(row);}
private static Plan decode(String input){Object loaded=yaml().load(input);if(!(loaded instanceof Map<?,?> row)||!"1".equals(String.valueOf(row.get("schema-version"))))throw new IllegalArgumentException("Invalid party operation schema");return new Plan(UUID.fromString(text(row,"operation-id")),Kind.valueOf(text(row,"kind")),UUID.fromString(text(row,"actor")),new BigInteger(text(row,"expected-revision")),new BigInteger(text(row,"amount")),optional(row,"name"),uuid(row,"party"),uuid(row,"target"),row.containsKey("expected-party-revision")?new BigInteger(text(row,"expected-party-revision")):null);}
private static String text(Map<?,?> row,String key){Object value=row.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException("Invalid "+key);}
private static String optional(Map<?,?> row,String key){return row.containsKey(key)?text(row,key):null;}private static UUID uuid(Map<?,?> row,String key){String value=optional(row,key);return value==null?null:UUID.fromString(value);}
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);options.setCodePointLimit(65536);return new Yaml(new SafeConstructor(options));}
private static void write(Path target,String content)throws IOException{Path temporary=Files.createTempFile(target.getParent(),"party-operation-",".tmp");try{byte[] bytes=content.getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,target,StandardCopyOption.ATOMIC_MOVE);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic party-operation persistence is unavailable",unsupported);}DurableFiles.forceDirectory(target.getParent());}finally{Files.deleteIfExists(temporary);}}
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Party operation store closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -0,0 +1,91 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.shop.ArmorCatalog;
import games.dmg.fantasystick.shop.ArmorProduct;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.*;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Forced atomic persistence for the one shared armor catalog. */
public final class YamlArmorCatalogRepository implements ArmorCatalogRepository {
private final Path file;
public YamlArmorCatalogRepository(Path data){file=data.toAbsolutePath().resolve("armor-catalog.yml");}
@Override public Optional<ArmorCatalog> load() throws IOException {
if(Files.notExists(file))return Optional.empty();
try {
if(Files.size(file)>1_048_576)throw new IllegalArgumentException("Catalog too large");
Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));
if(!(decoded instanceof Map<?,?> root)||Integer.parseInt(String.valueOf(root.get("schema-version")))!=1)
throw new IllegalArgumentException("Invalid schema");
var products=new LinkedHashMap<ArmorProduct,ArmorCatalog.Offer>();
for(Object value:list(root.get("products"))) {
var row=map(value);
var product=parseProduct(text(row,"product"));
if(products.put(product,new ArmorCatalog.Offer(new BigInteger(text(row,"price")),
Integer.parseInt(text(row,"remaining"))))!=null)throw new IllegalArgumentException("Duplicate product");
}
var transactions=new LinkedHashMap<UUID,ArmorCatalog.Transaction>();
Object savedTransactions=root.get("transactions");
if(savedTransactions!=null)for(Object value:list(savedTransactions)) {
var row=map(value);var id=UUID.fromString(text(row,"operation-id"));
var transaction=new ArmorCatalog.Transaction(parseProduct(text(row,"product")),
ArmorCatalog.TransactionState.valueOf(text(row,"state")));
if(transactions.put(id,transaction)!=null)throw new IllegalArgumentException("Duplicate operation");
}
return Optional.of(new ArmorCatalog(LocalDate.parse(text(root,"catalog-day")),
UUID.fromString(text(root,"revision")),Instant.parse(text(root,"refreshed-at")),
ArmorCatalog.RefreshReason.valueOf(text(root,"refresh-reason")),products,transactions));
} catch(RuntimeException invalid) { throw new IOException("Invalid armor catalog",invalid); }
}
@Override public void save(ArmorCatalog catalog) throws IOException {
Objects.requireNonNull(catalog);
Map<String,Object> root=new LinkedHashMap<>();
root.put("schema-version",1);
root.put("catalog-day",catalog.day().toString());
root.put("revision",catalog.revision().toString());
root.put("refreshed-at",catalog.refreshedAt().toString());
root.put("refresh-reason",catalog.reason().name());
root.put("products",catalog.products().entrySet().stream().sorted(Map.Entry.comparingByKey(
Comparator.comparing((ArmorProduct value)->value.slot().name()).thenComparing(value->value.tier().name())))
.map(entry->{Map<String,Object> row=new LinkedHashMap<>();row.put("product",product(entry.getKey()));
row.put("price",entry.getValue().price().toString());row.put("remaining",Integer.toString(entry.getValue().remaining()));return row;}).toList());
root.put("transactions",catalog.transactions().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->{
Map<String,Object> row=new LinkedHashMap<>();row.put("operation-id",entry.getKey().toString());
row.put("product",product(entry.getValue().product()));row.put("state",entry.getValue().state().name());return row;}).toList());
write(yaml().dump(root));
}
private void write(String content)throws IOException {
Files.createDirectories(file.getParent());
Path temporary=Files.createTempFile(file.getParent(),"armor-catalog-",".tmp");
try {
byte[] bytes=content.getBytes(StandardCharsets.UTF_8);
try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){
var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);
}
try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}
catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic armor catalog persistence unavailable",unsupported);}
DurableFiles.forceDirectory(file.getParent());
} finally { Files.deleteIfExists(temporary); }
}
private static String product(ArmorProduct product){return product.slot().name()+"-"+product.tier().name();}
private static ArmorProduct parseProduct(String value){int split=value.indexOf('-');if(split<1)throw new IllegalArgumentException();return new ArmorProduct(
GearSlot.valueOf(value.substring(0,split)),GearTier.valueOf(value.substring(split+1)));}
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);
options.setCodePointLimit(1_048_576);return new Yaml(new SafeConstructor(options));}
private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}
private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}
private static String text(Map<?,?> map,String key){Object value=map.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException("Invalid "+key);}
}
@@ -132,6 +132,7 @@ public final class YamlCharacterRepository {
var attributes = Files.readAttributes(temporary, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
// Never fall back to an in-place, partially written character record.
replacement.move(temporary, file(character.id()));
DurableFiles.forceDirectory(directory);
return new Creation(character, attributes, bytes);
} finally {
Files.deleteIfExists(temporary);
@@ -0,0 +1,9 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.shop.FantasyArmorer;import java.io.*;import java.nio.charset.StandardCharsets;import java.nio.file.*;import java.util.*;import org.yaml.snakeyaml.*;import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Forced atomic desired-state adapter for Fantasy Armorer registrations. */
public final class YamlFantasyArmorerRepository{
private final Path file;public YamlFantasyArmorerRepository(Path data){file=data.toAbsolutePath().resolve("fantasy-armorers.yml");}
public List<FantasyArmorer> load()throws IOException{if(Files.notExists(file))return List.of();try{Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||Integer.parseInt(root.get("schema-version").toString())!=1)throw new IllegalArgumentException();var result=new ArrayList<FantasyArmorer>();for(Object value:list(root.get("shops"))){var row=map(value);result.add(new FantasyArmorer(UUID.fromString(text(row,"shop-id")),text(row,"name"),UUID.fromString(text(row,"world")),Double.parseDouble(text(row,"x")),Double.parseDouble(text(row,"y")),Double.parseDouble(text(row,"z")),Float.parseFloat(text(row,"yaw")),Float.parseFloat(text(row,"pitch"))));}validate(result);return List.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid Fantasy Armorer file",invalid);}}
public void save(List<FantasyArmorer> values)throws IOException{validate(values);Map<String,Object> root=new LinkedHashMap<>();root.put("schema-version",1);root.put("shops",values.stream().sorted(Comparator.comparing(FantasyArmorer::id)).map(value->{Map<String,Object> row=new LinkedHashMap<>();row.put("shop-id",value.id().toString());row.put("name",value.name());row.put("world",value.world().toString());row.put("x",Double.toString(value.x()));row.put("y",Double.toString(value.y()));row.put("z",Double.toString(value.z()));row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));return row;}).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"armorers-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic Armorer persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
private static void validate(List<FantasyArmorer> values){var ids=new HashSet<UUID>();if(values.stream().anyMatch(value->!ids.add(value.id())))throw new IllegalArgumentException("Duplicate Armorer identity");}private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);options.setCodePointLimit(1_048_576);return new Yaml(new SafeConstructor(options));}private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}private static String text(Map<?,?> map,String key){Object value=map.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException();}
}
@@ -0,0 +1,25 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.party.PartyRegistrar;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Atomic desired-state adapter for administrator-managed Party Registrars. */
public final class YamlPartyRegistrarRepository {
private final Path file;
public YamlPartyRegistrarRepository(Path data){file=data.toAbsolutePath().resolve("party-registrars.yml");}
public List<PartyRegistrar> load()throws IOException{if(Files.notExists(file))return List.of();try{Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||number(root.get("schema-version"))!=1)throw new IllegalArgumentException();var result=new ArrayList<PartyRegistrar>();for(Object value:list(root.get("registrars"))){var row=map(value);result.add(new PartyRegistrar(UUID.fromString(text(row.get("registrar-id"))),text(row.get("name")),UUID.fromString(text(row.get("world"))),decimal(row.get("x")),decimal(row.get("y")),decimal(row.get("z")),(float)decimal(row.get("yaw")),(float)decimal(row.get("pitch"))));}validate(result);return List.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid Party Registrar file",invalid);}}
public void save(List<PartyRegistrar> registrars)throws IOException{validate(registrars);Map<String,Object> root=new LinkedHashMap<>();root.put("schema-version",1);root.put("registrars",registrars.stream().sorted(Comparator.comparing(PartyRegistrar::id)).map(value->{Map<String,Object> row=new LinkedHashMap<>();row.put("registrar-id",value.id().toString());row.put("name",value.name());row.put("world",value.world().toString());row.put("x",Double.toString(value.x()));row.put("y",Double.toString(value.y()));row.put("z",Double.toString(value.z()));row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));return row;}).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"party-registrars-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic Registrar persistence is unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
private static void validate(List<PartyRegistrar> values){var ids=new HashSet<UUID>();if(values.stream().anyMatch(value->!ids.add(value.id())))throw new IllegalArgumentException("Duplicate Registrar identity");}
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);options.setCodePointLimit(1_048_576);return new Yaml(new SafeConstructor(options));}
private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}
private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}
private static String text(Object value){if(value instanceof String result)return result;throw new IllegalArgumentException();}
private static int number(Object value){return Integer.parseInt(value.toString());}
private static double decimal(Object value){return Double.parseDouble(text(value));}
}
@@ -0,0 +1,116 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.party.CharacterParty;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Blocking, atomic whole-roster YAML adapter; callers keep it off the server tick thread. */
public final class YamlPartyRepository {
private final Path file;
public YamlPartyRepository(Path pluginDataDirectory) {
file = pluginDataDirectory.toAbsolutePath().resolve("parties.yml");
}
public List<CharacterParty> load() throws IOException {
if (Files.notExists(file)) return List.of();
String input = Files.readString(file, StandardCharsets.UTF_8);
if (input.length() > 1_048_576) throw new IOException("Party file exceeds size limit");
try {
Object decoded = yaml().load(input);
if (!(decoded instanceof Map<?, ?> root) || number(root.get("schema-version")).intValueExact() != 1) {
throw new IllegalArgumentException("Unsupported party schema");
}
var parties = new ArrayList<CharacterParty>();
for (Object value : list(root.get("parties"))) parties.add(decode(object(value)));
validate(parties);
return List.copyOf(parties);
} catch (RuntimeException malformed) {
throw new IOException("Invalid party file", malformed);
}
}
public void save(List<CharacterParty> parties) throws IOException {
validate(parties);
Map<String, Object> root = new LinkedHashMap<>();
root.put("schema-version", 1);
var rows = new ArrayList<Map<String, Object>>();
parties.stream().sorted(java.util.Comparator.comparing(CharacterParty::id)).forEach(party -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("party-id", party.id().toString()); row.put("revision", party.revision().toString());
row.put("display-name", party.displayName()); row.put("canonical-name", party.canonicalName());
row.put("leader", party.leader().toString());
if(party.lastPaidOperation()!=null)row.put("last-paid-operation",party.lastPaidOperation().toString());
row.put("members", party.members().stream().sorted().map(UUID::toString).toList());
row.put("invitations", party.invitations().stream().sorted(java.util.Comparator.comparing(CharacterParty.Invitation::id))
.map(invitation -> Map.of("invitation-id", invitation.id().toString(),
"character", invitation.character().toString())).toList());
rows.add(row);
});
root.put("parties", rows);
Files.createDirectories(file.getParent());
Path temporary = Files.createTempFile(file.getParent(), "parties-", ".tmp");
try {
byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,java.nio.file.StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}
try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
catch (java.nio.file.AtomicMoveNotSupportedException unsupported) {
throw new IOException("Atomic party persistence is unavailable", unsupported);
}
DurableFiles.forceDirectory(file.getParent());
} finally { Files.deleteIfExists(temporary); }
}
private static CharacterParty decode(Map<?, ?> row) {
var invitations = list(row.get("invitations")).stream().map(YamlPartyRepository::object)
.map(invitation -> new CharacterParty.Invitation(UUID.fromString(text(invitation.get("invitation-id"))),
UUID.fromString(text(invitation.get("character"))))).toList();
Set<UUID> members = list(row.get("members")).stream().map(YamlPartyRepository::text)
.map(UUID::fromString).collect(java.util.stream.Collectors.toUnmodifiableSet());
return new CharacterParty(UUID.fromString(text(row.get("party-id"))), number(row.get("revision")),
text(row.get("display-name")), text(row.get("canonical-name")), UUID.fromString(text(row.get("leader"))),
members, invitations,row.containsKey("last-paid-operation")?UUID.fromString(text(row.get("last-paid-operation"))):null);
}
private static void validate(List<CharacterParty> parties) {
var ids = new HashSet<UUID>(); var names = new HashSet<String>(); var members = new HashSet<UUID>();
for (var party : parties) {
if (!ids.add(party.id()) || !names.add(party.canonicalName())
|| party.members().stream().anyMatch(member -> !members.add(member))) {
throw new IllegalArgumentException("Duplicate party identity, name or membership");
}
}
}
private static Yaml yaml() {
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(0); options.setCodePointLimit(1_048_576);
return new Yaml(new SafeConstructor(options));
}
private static Map<?, ?> object(Object value) {
if (value instanceof Map<?, ?> map) return map; throw new IllegalArgumentException("Expected mapping");
}
private static List<?> list(Object value) {
if (value instanceof List<?> values) return values; throw new IllegalArgumentException("Expected list");
}
private static String text(Object value) {
if (value instanceof String text) return text; throw new IllegalArgumentException("Expected text");
}
private static BigInteger number(Object value) {
if (value instanceof Number || value instanceof BigInteger) return new BigInteger(value.toString());
return new BigInteger(text(value));
}
}
@@ -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,9 @@ 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.PartyEconomySettings partyEconomySettings;
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
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;
@@ -75,6 +78,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
private games.dmg.fantasystick.passive.HealthSteal healthSteal;
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
private games.dmg.fantasystick.party.PartyRegistrarDirectory partyRegistrars;
private games.dmg.fantasystick.party.PartyRegistrarController partyRegistrarController;
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -301,6 +309,9 @@ 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 attachPartyEconomySettings(games.dmg.fantasystick.persistence.PartyEconomySettings settings){if(closed||partyEconomySettings!=null)throw new IllegalStateException("Party economy settings already attached or command closed");partyEconomySettings=Objects.requireNonNull(settings);}
public void attachGearSettings(games.dmg.fantasystick.persistence.GearSettings settings){if(closed||gearSettings!=null)throw new IllegalStateException("Gear settings already attached or command closed");gearSettings=Objects.requireNonNull(settings);}
public void attachCoinSettings(games.dmg.fantasystick.persistence.CoinSettings settings){if(closed||coinSettings!=null)throw new IllegalStateException("Coin settings already attached or command closed");coinSettings=Objects.requireNonNull(settings);}
public void 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) {
@@ -348,12 +359,25 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
characterIssuer = Objects.requireNonNull(issuer);
}
public void attachPartyRegistrars(games.dmg.fantasystick.party.PartyRegistrarDirectory directory,
games.dmg.fantasystick.party.PartyRegistrarController controller) {
if (closed || partyRegistrars != null) throw new IllegalStateException("Party Registrars already attached or command closed");
partyRegistrars=Objects.requireNonNull(directory); partyRegistrarController=Objects.requireNonNull(controller);
}
public void attachArmorers(games.dmg.fantasystick.shop.FantasyArmorerDirectory directory,games.dmg.fantasystick.shop.FantasyArmorerController controller){if(closed||armorers!=null)throw new IllegalStateException("Armorers already attached or command closed");armorers=Objects.requireNonNull(directory);armorerController=Objects.requireNonNull(controller);}
public void attachArmorMarket(games.dmg.fantasystick.shop.ArmorMarket market){if(closed||armorMarket!=null)throw new IllegalStateException("Armor market already attached or command closed");armorMarket=Objects.requireNonNull(market);}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (closed) return true;
if (!sender.hasPermission(PERMISSION)) {
sender.sendMessage("You need fantasystick.admin to administer Fantasy Stick.");
return true;
}
if(args.length>0&&args[0].equals("shop")){shop(sender,args);return true;}
if (args.length > 0 && args[0].equals("party-registrar")) {
partyRegistrar(sender,args); return true;
}
if (args.length > 0 && args[0].equals("config")) {
configuration(sender, args);
return true;
@@ -478,6 +502,33 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fantasystick revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
private void shop(CommandSender sender,String[] args){
if(armorers==null||armorerController==null||args.length<2){shopUsage(sender);return;}
if(args[1].equals("restock")&&args.length==2&&armorMarket!=null){armorMarket.restock().whenComplete((catalog,failure)->{
if(failure!=null)report(null,"shop-restock",failure);if(closed)return;try{nextTick.accept(()->{
if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
if(failure!=null)sender.sendMessage("Fantasy Armorer restock failed; the prior catalog is unchanged.");
else sender.sendMessage("Restocked all Fantasy Armorers with catalog "+catalog.revision()+"; the next automatic restock remains 00:00 UTC.");
});}catch(RuntimeException rejected){report(null,"shop-restock-response",rejected);}});return;}
if(args[1].equals("list")&&args.length==2){var values=armorers.all();if(values.isEmpty())sender.sendMessage("No Fantasy Armorers are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Fantasy Armorer":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}
if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();java.util.concurrent.CompletableFuture<games.dmg.fantasystick.shop.FantasyArmorer> operation;try{operation=armorers.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch());}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer name is invalid.");return;}operation.whenComplete((saved,failure)->placeShopResponse(player,location,saved,failure));return;}
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer UUID is invalid.");return;}armorers.remove(id).whenComplete((removed,failure)->removeShopResponse(sender,id,removed,failure));return;}
shopUsage(sender);
}
private static void shopUsage(CommandSender sender){sender.sendMessage("Usage: /fantasystick shop <place [name...]|remove <shop-uuid>|list|restock>");}
private void placeShopResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.shop.FantasyArmorer saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))armorerController.reconcile(location.getWorld().getChunkAt(x,z));if(player.isOnline())player.sendMessage("Placed Fantasy Armorer "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"shop-entity",entityFailure);armorers.remove(saved.id()).whenComplete((ignored,rollback)->nextTick.accept(()->{armorerController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");}));}});}
private void removeShopResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.shop.FantasyArmorer> removed,Throwable failure){if(failure!=null)report(null,"shop-remove",failure);if(closed)return;nextTick.accept(()->{if(failure!=null)sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Fantasy Armorer not found.");else try{armorerController.removeLoaded(id);sender.sendMessage("Removed Fantasy Armorer "+id+".");}catch(RuntimeException entityFailure){var value=removed.orElseThrow();report(null,"shop-entity-remove",entityFailure);armorers.restore(value).whenComplete((restored,rollback)->{if(rollback!=null)report(null,"shop-remove-rollback",rollback);if(closed)return;nextTick.accept(()->{if(rollback==null){var world=server.getWorld(value.world());if(world!=null&&world.isChunkLoaded(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4))armorerController.reconcile(world.getChunkAt(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4));sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");}else sender.sendMessage("Fantasy Armorer removal rollback failed; inspect diagnostics.");});});}});}
private void placeRegistrarResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.party.PartyRegistrar saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"party-registrar-place",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");return;}try{int chunkX=location.getBlockX()>>4,chunkZ=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(location.getWorld().getChunkAt(chunkX,chunkZ));if(player.isOnline())player.sendMessage("Placed Party Registrar "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"party-registrar-entity",entityFailure);partyRegistrars.remove(saved.id()).whenComplete((ignored,rollbackFailure)->{if(rollbackFailure!=null)report(player.getUniqueId(),"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{partyRegistrarController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");});});}});}catch(RuntimeException rejected){report(player.getUniqueId(),"party-registrar-response",rejected);}}
private void removeRegistrarResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.party.PartyRegistrar> removed,Throwable failure){if(failure!=null)report(null,"party-registrar-remove",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null)sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Party Registrar not found.");else try{partyRegistrarController.removeLoaded(id);sender.sendMessage("Removed Party Registrar "+id+".");}catch(RuntimeException entityFailure){report(null,"party-registrar-entity",entityFailure);partyRegistrars.restore(removed.orElseThrow()).whenComplete((restored,rollbackFailure)->{if(rollbackFailure!=null)report(null,"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{if(rollbackFailure==null){var world=server.getWorld(restored.world());int chunkX=((int)Math.floor(restored.x()))>>4,chunkZ=((int)Math.floor(restored.z()))>>4;if(world!=null&&world.isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(world.getChunkAt(chunkX,chunkZ));}sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");});});}});}catch(RuntimeException rejected){report(null,"party-registrar-response",rejected);}}
private void partyRegistrar(CommandSender sender,String[] args){
if(partyRegistrars==null||partyRegistrarController==null||args.length<2){sender.sendMessage("Usage: /fantasystick party-registrar <place [name...]|remove <registrar-uuid>|list>");return;}
if(args[1].equals("list")&&args.length==2){var values=partyRegistrars.all();if(values.isEmpty())sender.sendMessage("No Party Registrars are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Party Registrar":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}
if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();java.util.concurrent.CompletableFuture<games.dmg.fantasystick.party.PartyRegistrar> operation;try{operation=partyRegistrars.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch());}catch(IllegalArgumentException invalid){sender.sendMessage("Party Registrar name is invalid.");return;}operation.whenComplete((saved,failure)->placeRegistrarResponse(player,location,saved,failure));return;}
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party Registrar UUID is invalid.");return;}partyRegistrars.remove(id).whenComplete((removed,failure)->removeRegistrarResponse(sender,id,removed,failure));return;}
sender.sendMessage("Usage: /fantasystick party-registrar <place [name...]|remove <registrar-uuid>|list>");
}
private void give(CommandSender sender, String[] args) {
if (args.length != 2 || args[1].isBlank() || characterIssuer == null) {
sender.sendMessage("Usage: /fantasystick give <online-player>"); return;
@@ -508,6 +559,9 @@ 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 (partyEconomySettings != null && games.dmg.fantasystick.persistence.PartyEconomySettings.keys().contains(args[1])) { partyEconomyConfiguration(sender,args); return; }
if(gearSettings!=null&&games.dmg.fantasystick.persistence.GearSettings.keys().contains(args[1])){gearConfiguration(sender,args);return;}
if (coinSettings != null && games.dmg.fantasystick.persistence.CoinSettings.keys().contains(args[1])) { coinConfiguration(sender,args); return; }
if (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 +1154,12 @@ 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 partyEconomyConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+partyEconomySettings.value(key)+" (applies to future party transactions only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartyEconomySettings.Update> operation;try{operation=partyEconomySettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party price is invalid; enter a whole number from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&partyEconomySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party price save failed; the effective price is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+partyEconomySettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future party transactions.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void gearConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+gearSettings.value(key)+" (applies to the next armor restock only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GearSettings.Update> operation;try{operation=gearSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Armor market setting is invalid; variance is 0–100, prices are nonnegative, and positive stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&gearSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Armor market setting save failed; effective settings and the current catalog are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+gearSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for the next automatic or administrative restock; the current catalog is unchanged.");});});}
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);}});}
@@ -1144,7 +1204,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (closed || !sender.hasPermission(PERMISSION)) return List.of();
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
? List.of("config", "experience", "give", "inspect", "recover", "revive") : List.of("config", "experience", "give", "inspect", "recover"), args[0]);
? List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "revive","shop") : List.of("config", "experience", "give", "inspect", "party-registrar", "recover","shop"), args[0]);
if(args.length==2&&args[0].equals("shop"))return matching(sender instanceof org.bukkit.entity.Player?List.of("list","place","remove","restock"):List.of("list","remove","restock"),args[1]);
if(args.length==3&&args[0].equals("shop")&&args[1].equals("remove")&&armorers!=null)return matching(armorers.all().stream().map(value->value.id().toString()).toList(),args[2]);
if (args.length == 2 && args[0].equals("party-registrar")) return matching(
sender instanceof org.bukkit.entity.Player ? List.of("list","place","remove") : List.of("list","remove"),args[1]);
if (args.length == 3 && args[0].equals("party-registrar") && args[1].equals("remove") && partyRegistrars!=null)
return matching(partyRegistrars.all().stream().map(value->value.id().toString()).toList(),args[2]);
if (args.length == 2 && args[0].equals("experience")) return matching(List.of("give"), args[1]);
if (args.length >= 3 && args[0].equals("experience") && args[1].equals("give")) return experienceNames(args);
if (args.length == 2 && (args[0].equals("inspect") || args[0].equals("recover"))) return matching(
@@ -1154,6 +1220,9 @@ 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.PartyEconomySettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.GearSettings.keys());
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 +1252,9 @@ 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(partyEconomySettings!=null&&games.dmg.fantasystick.persistence.PartyEconomySettings.keys().contains(args[1])){String current=partyEconomySettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.PartyEconomySettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
if(gearSettings!=null&&games.dmg.fantasystick.persistence.GearSettings.keys().contains(args[1])){String current=gearSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.GearSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
if(coinSettings!=null&&games.dmg.fantasystick.persistence.CoinSettings.keys().contains(args[1])){String current=coinSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.CoinSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
if(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])) {
@@ -4,6 +4,7 @@ import games.dmg.fantasystick.crafting.ManualDropDelivery;
import games.dmg.fantasystick.crafting.PlayerSheetDrops;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.items.EquipmentStorage;
import games.dmg.fantasystick.items.GearItems;
import games.dmg.fantasystick.items.SessionControls;
import io.papermc.paper.event.player.PlayerInventorySlotChangeEvent;
import java.util.Arrays;
@@ -41,6 +42,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
private final Consumer<EquipmentSessions.Failure> diagnostics;
private final CharacterSheets sheets = new CharacterSheets();
private SessionControls controls = new SessionControls();
private GearItems gear;
private static final class Intent { boolean started; }
private final Map<UUID, Intent> swaps = new HashMap<>();
private final Map<UUID, UUID> observed = new HashMap<>();
@@ -71,6 +73,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
}
private boolean closed;
private Consumer<ControlInput> inputs = ignored -> { };
private java.util.function.Predicate<org.bukkit.entity.Entity> managedInteractionTarget = ignored -> false;
private record Draw(Player player, ItemStack item, EquipmentSessions.SelectedControl selected) { }
private final Map<UUID, Draw> draws = new HashMap<>();
@@ -94,6 +97,13 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
controls = new SessionControls(Objects.requireNonNull(registry));
}
public void attachGear(GearItems gear){if(closed||this.gear!=null)throw new IllegalStateException("Gear already attached or events closed");this.gear=Objects.requireNonNull(gear);}
public void attachManagedInteractionTarget(java.util.function.Predicate<org.bukkit.entity.Entity> target) {
if (closed) throw new IllegalStateException("Events closed");
managedInteractionTarget = Objects.requireNonNull(target);
}
/** The input recipient belongs to this plugin; it does not authorize vanilla item use. */
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick,
Consumer<EquipmentSessions.Failure> diagnostics, Consumer<ControlInput> inputs) {
@@ -436,6 +446,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void useEntity(org.bukkit.event.player.PlayerInteractEntityEvent event) {
if (managedInteractionTarget.test(event.getRightClicked())) return;
var player = event.getPlayer();
var item = player.getInventory().getItem(event.getHand());
if (special(item)) event.setCancelled(true);
@@ -570,7 +581,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
private boolean active(Player player) { return sessions.active(player.getUniqueId()).isPresent(); }
private boolean control(ItemStack item) { return pickupClaim(item, "control_move_") || controls.identify(item).isPresent(); }
private boolean special(ItemStack item) { return control(item) || box(item) != null; }
private boolean special(ItemStack item) { return control(item) || box(item) != null || gear != null && gear.gear(item); }
private static boolean ownInventory(Player player, Inventory inventory) {
return inventory instanceof PlayerInventory && inventory.equals(player.getInventory());
}
@@ -2,6 +2,7 @@ package games.dmg.fantasystick.session;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.items.EquipmentStorage;
import games.dmg.fantasystick.items.GearItems;
import games.dmg.fantasystick.items.SessionControls;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
@@ -42,6 +43,9 @@ 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 java.util.function.Function<UUID,java.util.concurrent.CompletionStage<?>> partyDefeat =
ignored -> java.util.concurrent.CompletableFuture.completedStage(null);
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final Consumer<Notice> notices;
@@ -50,6 +54,7 @@ public final class EquipmentSessions implements AutoCloseable {
private final SessionClaims claims = new SessionClaims();
private final EquipmentStorage equipment = new EquipmentStorage();
private SessionControls controls;
private GearItems gear;
private final CharacterSheets sheets = new CharacterSheets();
private final Map<UUID, Attempt> pending = new HashMap<>();
private final Map<UUID, Attempt> owned = new HashMap<>();
@@ -152,6 +157,8 @@ public final class EquipmentSessions implements AutoCloseable {
}
/** Install before exposing entry so later checks/exits capture the acknowledged live policy. */
public synchronized void attachGear(GearItems gear){if(closed||this.gear!=null)throw new IllegalStateException("Gear already attached or sessions closed");this.gear=Objects.requireNonNull(gear);}
public synchronized void attachSwitchDelay(Supplier<Duration> policy) {
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
switchDelay = Objects.requireNonNull(policy);
@@ -164,6 +171,16 @@ 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);
}
public synchronized void attachPartyDefeat(java.util.function.Function<UUID,java.util.concurrent.CompletionStage<?>> mutation) {
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
partyDefeat = Objects.requireNonNull(mutation);
}
/** 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");
@@ -269,6 +286,7 @@ public final class EquipmentSessions implements AutoCloseable {
var previous = attempt.record;
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
attempt.record = saved; // Persisted truth precedes any reentrant native presentation setter.
if(gear!=null&&!previous.gear().equals(saved.gear()))gear.install(attempt.player.getInventory(),saved,attempt.owner,attempt.session);
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns()))
&& !refreshControls(attempt, session)) return false;
return true;
@@ -413,7 +431,7 @@ public final class EquipmentSessions implements AutoCloseable {
// Even malformed/foreign remnants are unsafe to pack as ordinary equipment.
return item.getItemMeta().getPersistentDataContainer().getKeys().stream().anyMatch(key ->
key.getNamespace().equals("fantasystick")
&& (key.getKey().startsWith("storage_") || key.getKey().startsWith("control_")));
&& (key.getKey().startsWith("storage_") || key.getKey().startsWith("control_") || key.getKey().startsWith("character_gear")));
}
private synchronized void loaded(Attempt attempt, Loaded loaded, Throwable failure) {
@@ -508,6 +526,7 @@ public final class EquipmentSessions implements AutoCloseable {
attempt.player.getInventory().setItem(slot, items[slot]);
if (interrupted(attempt)) return;
}
if(gear!=null)gear.install(attempt.player.getInventory(),record,attempt.owner,attempt.session);
mark(attempt, EquipmentSessionMarker.Phase.PACKED);
if (vitals != null) {
attempt.vitalsStarted = true;
@@ -598,16 +617,24 @@ 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;
if (closed) { conversionUncertain(attempt, "Shutdown before Tail conversion"); return; }
try {
nextTick.accept(() -> {
synchronized (EquipmentSessions.this) { deliverTail(attempt, dead.orElseThrow()); }
});
} catch (RuntimeException error) { diagnostic(attempt, error); conversionUncertain(attempt, "Tail callback rejected"); }
if (closed) { conversionUncertain(attempt, "Shutdown before party defeat and Tail conversion"); return; }
java.util.concurrent.CompletionStage<?> partyMutation;
try { partyMutation = partyDefeat.apply(attempt.character); }
catch (RuntimeException error) { diagnostic(attempt,error); conversionUncertain(attempt,"Party defeat mutation rejected"); return; }
partyMutation.whenComplete((ignored,partyFailure) -> {
if (partyFailure != null) { diagnostic(attempt,partyFailure); conversionUncertain(attempt,"Party defeat mutation failed"); return; }
if (closed) { conversionUncertain(attempt,"Shutdown before Tail conversion"); return; }
try {
nextTick.accept(() -> {
synchronized (EquipmentSessions.this) { deliverTail(attempt, dead.orElseThrow()); }
});
} catch (RuntimeException error) { diagnostic(attempt, error); conversionUncertain(attempt, "Tail callback rejected"); }
});
});
try {
attempt.deathReference = copy(attempt.player.getInventory().getItemInOffHand());
@@ -771,7 +798,7 @@ public final class EquipmentSessions implements AutoCloseable {
}
mark(attempt, EquipmentSessionMarker.Phase.RESTORING);
var result = equipment.restore(attempt.player.getInventory(), attempt.owner, attempt.session,
item -> controls.owned(item, attempt.owner, attempt.session),
item -> controls.owned(item, attempt.owner, attempt.session)||(gear!=null&&gear.owned(item,attempt.owner,attempt.session)),
attempt.player.getItemOnCursor(), attempt.player::setItemOnCursor);
// Returned refusals are read-only. Exceptions must retain the ambiguous RESTORING phase.
mark(attempt, result == EquipmentStorage.RestoreResult.RESTORED
@@ -0,0 +1,85 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.GearSlot;
import games.dmg.fantasystick.persistence.GearTier;
import java.math.BigInteger;
import java.time.Instant;
import java.time.LocalDate;
import java.util.*;
/** One acknowledged server-wide generation shared by every Fantasy Armorer. */
public record ArmorCatalog(LocalDate day, UUID revision, Instant refreshedAt, RefreshReason reason,
Map<ArmorProduct, Offer> products, Map<UUID, Transaction> transactions) {
public enum RefreshReason { STARTUP, SCHEDULED, ADMINISTRATIVE }
public enum TransactionState { RESERVED, CONSUMED, CANCELLED }
public record Offer(BigInteger price, int remaining) {
public Offer {
Objects.requireNonNull(price);
if (price.signum() <= 0 || remaining < 0) throw new IllegalArgumentException("Invalid catalog offer");
}
}
public record Transaction(ArmorProduct product, TransactionState state) {
public Transaction { Objects.requireNonNull(product); Objects.requireNonNull(state); }
}
public ArmorCatalog(LocalDate day, UUID revision, Instant refreshedAt, RefreshReason reason,
Map<ArmorProduct, Offer> products) {
this(day, revision, refreshedAt, reason, products, Map.of());
}
public ArmorCatalog {
Objects.requireNonNull(day);
Objects.requireNonNull(revision);
Objects.requireNonNull(refreshedAt);
Objects.requireNonNull(reason);
products = Map.copyOf(products);
transactions = Map.copyOf(transactions);
var expected = new HashSet<ArmorProduct>();
for (var slot : GearSlot.values()) for (var tier : GearTier.values())
if (tier != GearTier.NONE) expected.add(new ArmorProduct(slot,tier));
if (!products.keySet().equals(expected)) throw new IllegalArgumentException("Catalog must contain all 18 products");
}
public Offer offer(ArmorProduct product) {
var offer = products.get(Objects.requireNonNull(product));
if (offer == null) throw new IllegalArgumentException("Product is absent from catalog");
return offer;
}
public ArmorCatalog reserve(UUID operation, ArmorProduct product) {
Objects.requireNonNull(operation);
var existing=transactions.get(operation);
if(existing!=null) {
if(!existing.product().equals(product))throw new IllegalArgumentException("Operation identity collision");
return this;
}
var offer=offer(product);
if(offer.remaining()==0)throw new IllegalStateException("Product is sold out");
var nextProducts=new LinkedHashMap<>(products);
nextProducts.put(product,new Offer(offer.price(),offer.remaining()-1));
var nextTransactions=new LinkedHashMap<>(transactions);
nextTransactions.put(operation,new Transaction(product,TransactionState.RESERVED));
return new ArmorCatalog(day,revision,refreshedAt,reason,nextProducts,nextTransactions);
}
public ArmorCatalog settle(UUID operation, boolean consumed) {
var transaction=transactions.get(Objects.requireNonNull(operation));
if(transaction==null)return this;
if(transaction.state()!=TransactionState.RESERVED)return this;
var nextProducts=new LinkedHashMap<>(products);
if(!consumed) {
var offer=offer(transaction.product());
nextProducts.put(transaction.product(),new Offer(offer.price(),Math.addExact(offer.remaining(),1)));
}
var nextTransactions=new LinkedHashMap<>(transactions);
nextTransactions.put(operation,new Transaction(transaction.product(),
consumed?TransactionState.CONSUMED:TransactionState.CANCELLED));
return new ArmorCatalog(day,revision,refreshedAt,reason,nextProducts,nextTransactions);
}
public ArmorCatalog prune(UUID operation) {
if(!transactions.containsKey(operation))return this;
var next=new LinkedHashMap<>(transactions); next.remove(operation);
return new ArmorCatalog(day,revision,refreshedAt,reason,products,next);
}
}
@@ -0,0 +1,45 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.GearSlot;
import games.dmg.fantasystick.persistence.GearTier;
import java.math.BigInteger;
import java.time.Instant;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.UUID;
/** Exact integer catalog generation without floating-point price drift. */
public final class ArmorCatalogGenerator {
@FunctionalInterface public interface InclusiveRoll { int roll(int minimum, int maximum); }
private static final BigInteger BASIS_POINTS = BigInteger.valueOf(10_000);
private ArmorCatalogGenerator() { }
public static ArmorCatalog generate(LocalDate day, Instant refreshedAt, ArmorCatalog.RefreshReason reason,
ArmorMarketRules rules, InclusiveRoll random) {
Objects.requireNonNull(rules);
Objects.requireNonNull(random);
var products = new LinkedHashMap<ArmorProduct, ArmorCatalog.Offer>();
for (var slot : GearSlot.values()) {
for (var tier : GearTier.values()) {
if (tier == GearTier.NONE) continue;
int spread = Math.multiplyExact(100, rules.variancePercent());
int offset = checked(random, -spread, spread);
var numerator = rules.basePrices().get(tier).multiply(BigInteger.valueOf(10_000L + offset));
var division = numerator.divideAndRemainder(BASIS_POINTS);
var price = division[0].add(division[1].signum() == 0 ? BigInteger.ZERO : BigInteger.ONE).max(BigInteger.ONE);
var range = rules.stock().get(tier);
int remaining = checked(random, range.minimum(), range.maximum());
products.put(new ArmorProduct(slot, tier), new ArmorCatalog.Offer(price, remaining));
}
}
return new ArmorCatalog(day, UUID.randomUUID(), refreshedAt, reason, products);
}
private static int checked(InclusiveRoll random, int minimum, int maximum) {
int result = random.roll(minimum, maximum);
if (result < minimum || result > maximum) throw new IllegalArgumentException("Random roll outside requested range");
return result;
}
}
@@ -0,0 +1,163 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.*;
import java.io.IOException;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Consumer;
import java.util.function.Supplier;
/** Serialized durable authority for the one shared daily armor catalog. */
public final class ArmorMarket implements AutoCloseable {
public enum PurchaseStatus { APPLIED, SOLD_OUT, STALE, INSUFFICIENT, NOT_UPGRADE, UNAVAILABLE, FAILED }
public record PurchaseOutcome(PurchaseStatus status, Optional<CharacterRecord> record, ArmorCatalog catalog) {
public PurchaseOutcome { Objects.requireNonNull(status); Objects.requireNonNull(record); Objects.requireNonNull(catalog); }
}
private final ArmorCatalogRepository catalogs;
private final ArmorPurchaseOperationStore operations;
private final CharacterStorage characters;
private final Supplier<ArmorMarketRules> rules;
private final Clock clock;
private final ArmorCatalogGenerator.InclusiveRoll random;
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-armor-market"));
private final CompletionStage<Void> readiness;
private volatile ArmorCatalog snapshot;
private volatile boolean initialized;
private volatile Consumer<ArmorCatalog> listener=ignored->{};
private boolean closed,operationsClosing;
private int pending;
private final CompletableFuture<Void> drained=new CompletableFuture<>();
public ArmorMarket(ArmorCatalogRepository catalogs, ArmorPurchaseOperationStore operations,
CharacterStorage characters, Supplier<ArmorMarketRules> rules, Clock clock,
ArmorCatalogGenerator.InclusiveRoll random) {
this.catalogs=Objects.requireNonNull(catalogs);this.operations=Objects.requireNonNull(operations);
this.characters=Objects.requireNonNull(characters);this.rules=Objects.requireNonNull(rules);
this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);
readiness=submit(()->{initialize();return null;});
}
public void attachListener(Consumer<ArmorCatalog> listener){this.listener=Objects.requireNonNull(listener);}
public Optional<ArmorCatalog> snapshot(){return Optional.ofNullable(snapshot);}
public Instant now(){return clock.instant();}
public CompletionStage<Void> ready(){return readiness;}
public CompletionStage<ArmorCatalog> current(){return readiness.thenCompose(ignored->submit(()->ensureCurrent(ArmorCatalog.RefreshReason.SCHEDULED)));}
public CompletionStage<ArmorCatalog> restock(){return readiness.thenCompose(ignored->submit(()->{
var current=ensureCurrent(ArmorCatalog.RefreshReason.SCHEDULED);
if(!current.transactions().isEmpty())throw new IOException("Armor purchases are unresolved");
var generated=ArmorCatalogGenerator.generate(LocalDate.now(clock.withZone(ZoneOffset.UTC)),
clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),
ArmorCatalog.RefreshReason.ADMINISTRATIVE,rules.get(),random);
persist(generated,true);return generated;
}));}
public static Instant nextScheduledRestock(Instant now){return Objects.requireNonNull(now).atZone(ZoneOffset.UTC)
.toLocalDate().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();}
public CompletionStage<PurchaseOutcome> purchase(UUID character, java.math.BigInteger expectedCharacterRevision,
UUID expectedCatalogRevision, ArmorProduct product) {
Objects.requireNonNull(character);Objects.requireNonNull(expectedCharacterRevision);
Objects.requireNonNull(expectedCatalogRevision);Objects.requireNonNull(product);
return readiness.thenCompose(ignored->submit(()->purchaseBlocking(character,expectedCharacterRevision,
expectedCatalogRevision,product)));
}
private void initialize() throws Exception {
snapshot=catalogs.load().orElse(null);
var plans=operations.load().toCompletableFuture().join();
if(snapshot==null&&!plans.isEmpty())throw new IOException("Armor catalog is missing for pending purchases");
for(var plan:plans)execute(plan);
if(snapshot!=null&&!snapshot.transactions().isEmpty()) {
var next=snapshot;
for(var entry:snapshot.transactions().entrySet()) {
if(entry.getValue().state()==ArmorCatalog.TransactionState.RESERVED)
throw new IOException("Orphan reserved armor purchase "+entry.getKey());
next=next.prune(entry.getKey());
}
persist(next,false);
}
ensureCurrent(ArmorCatalog.RefreshReason.STARTUP);
initialized=true;
}
private PurchaseOutcome purchaseBlocking(UUID character,java.math.BigInteger expectedCharacterRevision,
UUID expectedCatalogRevision,ArmorProduct product)throws Exception {
var catalog=ensureCurrent(ArmorCatalog.RefreshReason.SCHEDULED);
if(!catalog.transactions().isEmpty())return new PurchaseOutcome(PurchaseStatus.FAILED,Optional.empty(),catalog);
if(!catalog.revision().equals(expectedCatalogRevision))
return new PurchaseOutcome(PurchaseStatus.STALE,Optional.empty(),catalog);
var offer=catalog.offer(product);
if(offer.remaining()==0)return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);
var plan=new ArmorPurchaseOperationStore.Plan(UUID.randomUUID(),character,expectedCharacterRevision,
catalog.revision(),product,offer.price());
operations.prepare(plan).toCompletableFuture().join();
return execute(plan);
}
private PurchaseOutcome execute(ArmorPurchaseOperationStore.Plan plan)throws Exception {
var catalog=Objects.requireNonNull(snapshot,"Armor catalog unavailable");
if(!catalog.revision().equals(plan.catalogRevision()))
throw new IOException("Pending armor purchase belongs to another catalog revision");
var transaction=catalog.transactions().get(plan.id());
if(transaction==null) {
try { catalog=catalog.reserve(plan.id(),plan.product()); }
catch(IllegalStateException soldOut) {
operations.complete(plan.id()).toCompletableFuture().join();
return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);
}
persist(catalog,false);
transaction=catalog.transactions().get(plan.id());
}
if(!transaction.product().equals(plan.product()))throw new IOException("Armor reservation product mismatch");
if(transaction.state()!=ArmorCatalog.TransactionState.RESERVED) {
operations.complete(plan.id()).toCompletableFuture().join();
var pruned=catalog.prune(plan.id());persist(pruned,false);
var record=characters.find(plan.character()).join();
return new PurchaseOutcome(transaction.state()==ArmorCatalog.TransactionState.CONSUMED
?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,record,pruned);
}
CharacterStorage.GearPurchase purchase;
try {
purchase=characters.purchaseGear(plan.character(),plan.expectedCharacterRevision(),plan.id(),
plan.product().slot(),plan.product().tier(),plan.price()).join();
} catch(CompletionException failure) { throw failure; }
boolean consumed=purchase.status()==CharacterStorage.GearPurchaseStatus.APPLIED||
purchase.status()==CharacterStorage.GearPurchaseStatus.DUPLICATE;
catalog=catalog.settle(plan.id(),consumed);persist(catalog,consumed);
operations.complete(plan.id()).toCompletableFuture().join();
catalog=catalog.prune(plan.id());persist(catalog,false);
var status=switch(purchase.status()) {
case APPLIED,DUPLICATE->PurchaseStatus.APPLIED;
case INSUFFICIENT->PurchaseStatus.INSUFFICIENT;
case NOT_UPGRADE->PurchaseStatus.NOT_UPGRADE;
case STALE->PurchaseStatus.STALE;
case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;
};
return new PurchaseOutcome(status,purchase.record(),catalog);
}
private ArmorCatalog ensureCurrent(ArmorCatalog.RefreshReason reason)throws IOException {
LocalDate today=LocalDate.now(clock.withZone(ZoneOffset.UTC));
var current=snapshot;
if(current!=null&&current.day().equals(today))return current;
if(current!=null&&!current.transactions().isEmpty())throw new IOException("Armor purchases are unresolved at catalog rollover");
var generated=ArmorCatalogGenerator.generate(today,
clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),reason,rules.get(),random);
persist(generated,true);
return generated;
}
private void persist(ArmorCatalog catalog,boolean notify)throws IOException {
catalogs.save(catalog);snapshot=catalog;if(notify)publish(catalog);
}
private void publish(ArmorCatalog catalog){if(!initialized)return;try{listener.accept(catalog);}catch(RuntimeException ignored){}}
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(
new IOException("Armor market closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}
catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
private synchronized void finished(){pending--;if(closed&&pending==0)closeOperations();}
private void closeOperations(){if(operationsClosing)return;operationsClosing=true;operations.close();
operations.drained().whenComplete((ignored,failure)->{if(failure==null)drained.complete(null);else drained.completeExceptionally(failure);});}
public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)closeOperations();}
}
@@ -0,0 +1,34 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.GearTier;
import java.math.BigInteger;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
/** Validated configuration captured for one future catalog generation. */
public record ArmorMarketRules(Map<GearTier, BigInteger> basePrices, int variancePercent,
Map<GearTier, StockRange> stock) {
public record StockRange(int minimum, int maximum) {
public StockRange {
if (minimum <= 0 || maximum < minimum) throw new IllegalArgumentException("Invalid stock range");
}
}
public ArmorMarketRules {
Objects.requireNonNull(basePrices);
Objects.requireNonNull(stock);
if (variancePercent < 0 || variancePercent > 100) throw new IllegalArgumentException("Invalid variance");
var prices = new EnumMap<GearTier, BigInteger>(GearTier.class);
var ranges = new EnumMap<GearTier, StockRange>(GearTier.class);
for (var tier : GearTier.values()) {
if (tier == GearTier.NONE) continue;
var price = Objects.requireNonNull(basePrices.get(tier), "Missing base price");
if (price.signum() < 0) throw new IllegalArgumentException("Negative base price");
prices.put(tier, price);
ranges.put(tier, Objects.requireNonNull(stock.get(tier), "Missing stock range"));
}
basePrices = Map.copyOf(prices);
stock = Map.copyOf(ranges);
}
}
@@ -0,0 +1,14 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.GearSlot;
import games.dmg.fantasystick.persistence.GearTier;
import java.util.Objects;
/** One independently priced and stocked armor slot/tier product. */
public record ArmorProduct(GearSlot slot, GearTier tier) {
public ArmorProduct {
Objects.requireNonNull(slot);
Objects.requireNonNull(tier);
if (tier == GearTier.NONE) throw new IllegalArgumentException("None is not purchasable");
}
}
@@ -0,0 +1,88 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.time.Instant;
import java.util.*;
import java.util.function.Consumer;
import org.bukkit.entity.Player;
/** Active-character Armorer boundary backed by the one recoverable shared market. */
public final class ArmorShop {
public enum Status { APPLIED, INVALID_SESSION, SOLD_OUT, INSUFFICIENT, NOT_UPGRADE, STALE, FAILED }
public record Outcome(Status status,Optional<CharacterRecord> record){}
public record View(CharacterRecord record,UUID session,ArmorCatalog catalog){}
private record Context(EquipmentSessions.Active active,CharacterRecord record,FantasyArmorer shop){}
private final FantasyArmorerDirectory shops;
private final EquipmentSessions sessions;
private final ArmorMarket market;
private final Consumer<Runnable> nextTick;
private final Consumer<Throwable> failures;
public ArmorShop(FantasyArmorerDirectory shops,EquipmentSessions sessions,ArmorMarket market,
Consumer<Runnable> nextTick,Consumer<Throwable> failures){
this.shops=Objects.requireNonNull(shops);this.sessions=Objects.requireNonNull(sessions);
this.market=Objects.requireNonNull(market);this.nextTick=Objects.requireNonNull(nextTick);
this.failures=Objects.requireNonNull(failures);
}
public Optional<CharacterRecord> active(Player player,UUID shop){return context(player,shop).map(Context::record);}
public Optional<UUID> activeSession(Player player,UUID shop){return context(player,shop).map(value->value.active().session());}
public Instant now(){return market.now();}
public void view(Player player,UUID shopId,Consumer<Optional<View>> result){
var captured=context(player,shopId);
if(captured.isEmpty()){result.accept(Optional.empty());return;}
market.current().whenComplete((catalog,failure)->{
if(failure!=null){report(failure);publish(()->result.accept(Optional.empty()));return;}
publish(()->{
var current=context(player,shopId);
if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.orElseThrow().active().session()))
result.accept(Optional.empty());
else result.accept(Optional.of(new View(current.orElseThrow().record(),current.orElseThrow().active().session(),catalog)));
});
});
}
public void buy(Player player,UUID shopId,UUID catalogRevision,ArmorProduct product,Consumer<Outcome> result){
var lease=shops.acquire(shopId);
if(lease.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}
var held=lease.orElseThrow();
var context=context(player,shopId);
if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}
var captured=context.orElseThrow();
market.purchase(captured.record().id(),captured.record().revision(),catalogRevision,product).whenComplete((purchase,failure)->{
if(failure!=null){report(failure);publishHeld(held,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}
publishHeld(held,()->{
var current=context(player,shopId);
if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.active().session())){
result.accept(new Outcome(Status.STALE,purchase.record()));return;
}
Status status=switch(purchase.status()){
case APPLIED->Status.APPLIED;case SOLD_OUT->Status.SOLD_OUT;case INSUFFICIENT->Status.INSUFFICIENT;
case NOT_UPGRADE->Status.NOT_UPGRADE;case STALE->Status.STALE;
case UNAVAILABLE,FAILED->Status.FAILED;
};
if(purchase.status()==ArmorMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent())
sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());
result.accept(new Outcome(status,purchase.record()));
});
});
}
private Optional<Context> context(Player player,UUID shopId){
var shop=shops.find(shopId).orElse(null);
var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);
if(shop==null||record==null)return Optional.empty();
var location=player.getLocation();
if(location.getWorld()==null||!location.getWorld().getUID().equals(shop.world()))return Optional.empty();
double x=location.getX()-shop.x(),y=location.getY()-shop.y(),z=location.getZ()-shop.z();
return x*x+y*y+z*z<=36?Optional.of(new Context(active,record,shop)):Optional.empty();
}
private void publishHeld(FantasyArmorerDirectory.Lease lease,Runnable action){try{nextTick.accept(()->{try{action.run();}finally{lease.close();}});}
catch(RuntimeException failure){lease.close();report(failure);}}
private void publish(Runnable action){try{nextTick.accept(action);}catch(RuntimeException failure){report(failure);}}
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
}
@@ -0,0 +1,113 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.*;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.*;
import org.bukkit.*;
import org.bukkit.entity.Player;
import org.bukkit.event.*;
import org.bukkit.event.inventory.*;
import org.bukkit.inventory.*;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
/** Bounded shared-catalog armor shop; every click revalidates generation, character, session and shop. */
public final class ArmorShopMenus implements Listener,AutoCloseable {
private record Holder(UUID shop,UUID character,UUID session,UUID catalog)implements InventoryHolder{
public Inventory getInventory(){return null;}}
private final ArmorShop service;
private final org.bukkit.Server server;
private final NamespacedKey slotKey,tierKey;
private boolean closed;
public ArmorShopMenus(Plugin plugin,ArmorShop service){this.service=Objects.requireNonNull(service);server=plugin.getServer();
slotKey=new NamespacedKey(plugin,"armor_shop_slot");tierKey=new NamespacedKey(plugin,"armor_shop_tier");}
public void open(Player player,UUID shop){
service.view(player,shop,view->{
if(closed||!player.isOnline())return;
if(view.isEmpty()){
player.sendMessage("You must be actively playing a living fantasy character beside this Fantasy Armorer; the market must also be available.");return;
}
var current=view.orElseThrow();
var inventory=server.createInventory(new Holder(shop,current.record().id(),current.session(),current.catalog().revision()),
54,net.kyori.adventure.text.Component.text("Fantasy Armorer"));
int column=0;
for(var slot:GearSlot.values()){
int row=0;
for(var tier:GearTier.values()){
if(tier==GearTier.NONE)continue;
var product=new ArmorProduct(slot,tier);
inventory.setItem(row*9+column,item(product,current.catalog().offer(product)));
row++;
}
column+=3;
}
inventory.setItem(8,schedule(current.catalog(),service.now()));
player.openInventory(inventory);
});
}
private ItemStack item(ArmorProduct product,ArmorCatalog.Offer offer){
var tier=product.tier();var slot=product.slot();
var item=new ItemStack(Material.valueOf((tier==GearTier.GOLD?"GOLDEN":tier.name())+"_"+switch(slot){
case CHESTPLATE->"CHESTPLATE";case LEGGINGS->"LEGGINGS";case BOOTS->"BOOTS";}));
var meta=item.getItemMeta();
meta.displayName(net.kyori.adventure.text.Component.text("Buy "+title(tier.name())+" "+title(slot.name())+
" — "+offer.price()+" coins"));
meta.lore(List.of(net.kyori.adventure.text.Component.text("Stock: "+offer.remaining())));
meta.getPersistentDataContainer().set(slotKey,PersistentDataType.STRING,slot.name());
meta.getPersistentDataContainer().set(tierKey,PersistentDataType.STRING,tier.name());
item.setItemMeta(meta);return item;
}
private static ItemStack schedule(ArmorCatalog catalog,Instant now){
var next=catalog.day().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
long seconds=Math.max(0,Duration.between(now,next).toSeconds());
long hours=seconds/3600,minutes=(seconds%3600)/60;
var item=new ItemStack(Material.CLOCK);var meta=item.getItemMeta();
meta.displayName(net.kyori.adventure.text.Component.text("Restock in: "+hours+"h "+minutes+"m"));
item.setItemMeta(meta);return item;
}
public void refreshOpen(){
if(closed)return;
for(var player:server.getOnlinePlayers()){
var view=player.getOpenInventory();
if(view==null)continue;
var inventory=view.getTopInventory();
if(inventory!=null&&inventory.getHolder() instanceof Holder holder)open(player,holder.shop());
}
}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)
public void click(InventoryClickEvent event){
if(!(event.getInventory().getHolder() instanceof Holder holder)||!(event.getWhoClicked() instanceof Player player))return;
event.setCancelled(true);
if(closed||!service.active(player,holder.shop()).map(value->value.id().equals(holder.character())).orElse(false)||
!service.activeSession(player,holder.shop()).map(holder.session()::equals).orElse(false)){
player.sendMessage("Armor purchase refused: the character, session or shop changed.");return;
}
var item=event.getCurrentItem();if(item==null)return;
var data=item.getItemMeta().getPersistentDataContainer();
try{
var product=new ArmorProduct(GearSlot.valueOf(data.get(slotKey,PersistentDataType.STRING)),
GearTier.valueOf(data.get(tierKey,PersistentDataType.STRING)));
service.buy(player,holder.shop(),holder.catalog(),product,outcome->{
if(outcome.status()==ArmorShop.Status.APPLIED)player.sendMessage("Purchased "+title(product.tier().name())+" "+title(product.slot().name())+".");
else{
player.sendMessage("Armor purchase refused: "+outcome.status().name().toLowerCase(Locale.ROOT).replace('_',' ')+".");
if(outcome.status()==ArmorShop.Status.STALE||outcome.status()==ArmorShop.Status.SOLD_OUT)open(player,holder.shop());
}
});
}catch(RuntimeException ignored){ }
}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)
public void drag(InventoryDragEvent event){if(event.getInventory().getHolder() instanceof Holder)event.setCancelled(true);}
private static String title(String value){String lower=value.toLowerCase(Locale.ROOT).replace('_',' ');
return Character.toUpperCase(lower.charAt(0))+lower.substring(1);}
@Override public void close(){closed=true;}
}
@@ -0,0 +1,4 @@
package games.dmg.fantasystick.shop;
import java.util.*;
/** Durable desired location for one administrator-managed Fantasy Armorer. */
public record FantasyArmorer(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch){public FantasyArmorer{Objects.requireNonNull(id);Objects.requireNonNull(name);Objects.requireNonNull(world);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(codePoint->{int type=Character.getType(codePoint);return type==Character.CONTROL||type==Character.FORMAT||type==Character.SURROGATE;})||!Double.isFinite(x)||!Double.isFinite(y)||!Double.isFinite(z)||!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid Fantasy Armorer");}}
@@ -0,0 +1,12 @@
package games.dmg.fantasystick.shop;
import java.util.*;import java.util.function.BiConsumer;import org.bukkit.*;import org.bukkit.entity.*;import org.bukkit.event.*;import org.bukkit.persistence.PersistentDataType;import org.bukkit.plugin.Plugin;
/** Loaded-chunk Armorer reconciliation and native protection, distinct from Party Registrars. */
public final class FantasyArmorerController implements Listener,AutoCloseable{
private final Plugin plugin;private final FantasyArmorerDirectory directory;private final BiConsumer<Player,UUID> interactions;private final NamespacedKey idKey,typeKey;private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private boolean closed;public FantasyArmorerController(Plugin plugin,FantasyArmorerDirectory directory,BiConsumer<Player,UUID> interactions){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.interactions=Objects.requireNonNull(interactions);idKey=new NamespacedKey(plugin,"fantasy_armorer_id");typeKey=new NamespacedKey(plugin,"managed_npc_type");}
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}}public boolean tagged(Entity entity){return "fantasy_armorer".equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,FantasyArmorer>();for(var value:directory.all())if(value.world().equals(chunk.getWorld().getUID())&&section(value.x())==chunk.getX()&&section(value.z())==chunk.getZ())desired.put(value.id(),value);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(!tagged(entity))continue;UUID id=id(entity);var target=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||target==null||!retained.add(id)){entity.remove();continue;}configure(villager,target);var current=villager.getLocation();var expected=location(target);if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawn(target);}}}for(var target:desired.values())if(!retained.contains(target.id()))spawn(target);}
public void removeLoaded(UUID id){for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity)&&id.equals(id(entity)))entity.remove();}}
private void spawn(FantasyArmorer value){var world=plugin.getServer().getWorld(value.world());if(world==null)return;int x=section(value.x()),z=section(value.z());if(world.isChunkLoaded(x,z))world.spawn(location(value),Villager.class,villager->configure(villager,value));}private void configure(Villager villager,FantasyArmorer value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,"fantasy_armorer");data.set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.name().isBlank()?"Fantasy Armorer":value.name()));villager.setCustomNameVisible(true);}private Location location(FantasyArmorer value){return new Location(plugin.getServer().getWorld(value.world()),value.x(),value.y(),value.z(),value.yaw(),value.pitch());}private UUID id(Entity entity){String value=entity.getPersistentDataContainer().get(idKey,PersistentDataType.STRING);try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}private static int section(double value){return((int)Math.floor(value))>>4;}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(org.bukkit.event.player.PlayerInteractEntityEvent event){if(!tagged(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=org.bukkit.inventory.EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked());if(id!=null&&directory.find(id).isPresent())interactions.accept(event.getPlayer(),id);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(org.bukkit.event.entity.EntityDamageEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void combust(org.bukkit.event.entity.EntityCombustEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void transform(org.bukkit.event.entity.EntityTransformEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void breed(org.bukkit.event.entity.EntityBreedEvent event){if(tagged(event.getMother())||tagged(event.getFather()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event){if(tagged(event.getEntered()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(org.bukkit.event.entity.PlayerLeashEntityEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(org.bukkit.event.entity.EntityTeleportEvent event){if(tagged(event.getEntity())&&!relocating.contains(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.MONITOR)public void chunk(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
@Override public void close(){closed=true;for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity))entity.remove();}}
}
@@ -0,0 +1,9 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository;import java.io.*;import java.util.*;import java.util.concurrent.*;
/** Serialized durable Fantasy Armorer desired state. */
public final class FantasyArmorerDirectory implements AutoCloseable{
private final YamlFantasyArmorerRepository repository;private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-armorer-persistence"));private volatile List<FantasyArmorer> snapshot;private final Map<UUID,Integer> leases=new HashMap<>();private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();public FantasyArmorerDirectory(YamlFantasyArmorerRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}public List<FantasyArmorer> all(){return snapshot;}public Optional<FantasyArmorer> find(UUID id){return snapshot.stream().filter(value->value.id().equals(id)).findFirst();}
public CompletableFuture<FantasyArmorer> place(String name,UUID world,double x,double y,double z,float yaw,float pitch){return submit(()->{synchronized(this){var value=new FantasyArmorer(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}public CompletableFuture<Optional<FantasyArmorer>> remove(UUID id){return submit(()->{synchronized(this){var found=find(id);if(found.isEmpty())return Optional.empty();if(leases.getOrDefault(id,0)>0)throw new IOException("Fantasy Armorer has an active purchase");var next=snapshot.stream().filter(value->!value.id().equals(id)).toList();repository.save(next);snapshot=next;return found;}});}public CompletableFuture<FantasyArmorer> restore(FantasyArmorer value){return submit(()->{synchronized(this){var found=find(value.id());if(found.isPresent()){if(found.orElseThrow().equals(value))return value;throw new IOException("Armorer identity collision");}var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}
public synchronized Optional<Lease> acquire(UUID id){if(closed||find(id).isEmpty())return Optional.empty();leases.merge(id,1,Integer::sum);return Optional.of(new Lease(id));}public final class Lease implements AutoCloseable{private final UUID id;private boolean released;private Lease(UUID id){this.id=id;}public FantasyArmorer shop(){return find(id).orElseThrow();}@Override public void close(){synchronized(FantasyArmorerDirectory.this){if(released)return;released=true;int remaining=leases.getOrDefault(id,0)-1;if(remaining<=0)leases.remove(id);else leases.put(id,remaining);}}}
private <T>CompletableFuture<T> submit(Callable<T> operation){var result=new CompletableFuture<T>();synchronized(this){if(closed){result.completeExceptionally(new IOException("Armorer directory closed"));return result;}pending++;}worker.execute(()->{try{result.complete(operation.call());}catch(Throwable failure){result.completeExceptionally(failure);}finally{finished();}});return result;}private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
+29
View File
@@ -7,6 +7,27 @@ admin-revive-selection-timeout-seconds: 60
character-swap-cooldown-seconds: 900
roleplay-disabled: false
party-max-size: 6
party-registration-price: 10
party-leader-change-price: 10
gear-price-leather: 2
gear-price-copper: 10
gear-price-gold: 25
gear-price-iron: 75
gear-price-diamond: 150
gear-price-netherite: 500
gear-price-variance-percent: 10
gear-stock-leather-min: 25
gear-stock-leather-max: 50
gear-stock-copper-min: 20
gear-stock-copper-max: 40
gear-stock-gold-min: 15
gear-stock-gold-max: 30
gear-stock-iron-min: 10
gear-stock-iron-max: 20
gear-stock-diamond-min: 5
gear-stock-diamond-max: 10
gear-stock-netherite-min: 1
gear-stock-netherite-max: 3
sheet-resource-pack-url: ''
sheet-resource-pack-sha1: ''
@@ -24,6 +45,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
+1 -1
View File
@@ -8,7 +8,7 @@ author: dmg.games
commands:
fantasystick:
description: Issue or revive sheets, grant fantasy experience, recover sessions or configure Fantasy Stick.
usage: /fantasystick <give|revive> <online-player> | experience give <active-character-name...> <amount> | <inspect|recover> <player-uuid> | config <setting> [value]
usage: /fantasystick <give|revive> <online-player> | experience give <active-character-name...> <amount> | <inspect|recover> <player-uuid> | config <setting> [value] | shop <place [name...]|remove <shop-uuid>|list|restock> | party-registrar <place [name...]|remove <registrar-uuid>|list>
permission: fantasystick.admin
permissions:
fantasystick.admin:
@@ -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());
}
@@ -326,10 +327,12 @@ class CharacterBookNativeTest {
assertTrue(pages.size() <= 100, "Keep each native linked-book view bounded");
assertTrue(pages.get(0).getString().contains("STR: 0 [+1 STR]"));
assertTrue(pages.get(0).getString().contains("INT: 12 [+1 INT]"));
assertTrue(pages.get(1).getString().startsWith("Active abilities"));
assertTrue(pages.get(1).getString().contains("3: No Ability"));
assertTrue(pages.get(1).getString().startsWith("Current gear"));
assertTrue(pages.get(1).getString().contains("Head: Cosmetic"));
assertTrue(pages.get(2).getString().startsWith("Active abilities"));
assertTrue(pages.get(2).getString().contains("3: No Ability"));
int index = firstPageStarting(pages, "Ability index");
assertTrue(index > 1);
assertTrue(index > 2);
var indexedNames = new java.util.ArrayList<String>();
int partyPage = firstPageStarting(pages, "Party");
for (int page = index; page < pages.size() && pages.get(page).getString().startsWith("Ability index"); page++) {
@@ -359,8 +362,8 @@ class CharacterBookNativeTest {
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals("fireball", saved.loadout().get(0));
var refreshed = nativePages(a);
assertTrue(refreshed.get(1).getString().contains("3: Fireball"));
var activeJump = changePage(refreshed.get(1), "Fireball");
assertTrue(refreshed.get(2).getString().contains("3: Fireball"));
var activeJump = changePage(refreshed.get(2), "Fireball");
assertNotNull(activeJump);
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nINT 12\n[Unlearn]"));
}
@@ -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());
@@ -33,6 +33,7 @@ class DefeatHealthDeliveryNativeTest {
f.dispatcher.accept(death);
if (a.nativePlayer.queuedHealthUpdatePacket != null) sent.add(a.nativePlayer.queuedHealthUpdatePacket);
a.nativePlayer.queueHealthUpdatePacket = false;
PartyLifecycleNativeTest.directory(f).defeat(java.util.UUID.randomUUID()).join();
PartyBookNativeTest.drain(f);
assertTrue(death.isCancelled());
assertEquals(a.maximumHealth.getValue(), a.player.getHealth());
@@ -53,7 +53,7 @@ class EquipmentAdminCommandNativeTest {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var target = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(target.core.id,target.player);
var sender=new Sender();
assertEquals(List.of("config","experience","give","inspect","recover"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
assertEquals(List.of("config","experience","give","inspect","party-registrar","recover","shop"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
assertEquals(List.of("give"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"g"}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Acc"}));
@@ -94,7 +94,7 @@ class EquipmentAdminCommandNativeTest {
f.players.put(first.core.id, first.player); f.players.put(second.core.id, second.player); f.players.put(offline.core.id, offline.player);
var sender = new Sender();
var expected = java.util.stream.Stream.of(first.core.id.toString(), second.core.id.toString()).sorted().toList();
assertEquals(List.of("config", "experience", "give", "inspect", "recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("inspect"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"in"}));
assertEquals(List.of("recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"rec"}));
assertEquals(expected, f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}));
@@ -0,0 +1,7 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.Mockito.*;import games.dmg.fantasystick.items.GearItems;import games.dmg.fantasystick.persistence.*;import java.math.BigInteger;import java.nio.file.Path;import java.util.*;import org.bukkit.Material;import org.bukkit.event.player.PlayerDropItemEvent;import org.bukkit.inventory.ItemStack;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
class FantasyArmorNativeTest{
@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void everyTierAndHelmetFreeSlotProducesBoundUnbreakableUnenchantedNativeArmor()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var gear=new GearItems(f.plugin);for(var slot:GearSlot.values())for(var tier:GearTier.values())if(tier!=GearTier.NONE){var item=gear.create(slot,tier,UUID.randomUUID(),UUID.randomUUID());assertEquals(Material.valueOf((tier==GearTier.GOLD?"GOLDEN":tier.name())+"_"+switch(slot){case CHESTPLATE->"CHESTPLATE";case LEGGINGS->"LEGGINGS";case BOOTS->"BOOTS";}),item.getType());assertTrue(item.getItemMeta().isUnbreakable());assertTrue(item.getEnchantments().isEmpty());}assertEquals(3,GearSlot.values().length);}}
@Test void purchasedGearMaterializesOnlyInFantasyModeIsContainedAndOrdinaryArmorRestores()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);var repository=new YamlCharacterRepository(data);var old=repository.load(actor.core.record.id()).orElseThrow();var funded=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(),false,null,null,BigInteger.valueOf(1000),true,old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());repository.replace(funded,old.revision());try(var storage=new CharacterStorage(repository,java.time.Clock.systemUTC())){storage.purchaseGear(funded.id(),funded.revision(),UUID.randomUUID(),GearSlot.CHESTPLATE,GearTier.IRON,BigInteger.valueOf(75)).join();}var ordinary=new ItemStack(Material.DIAMOND_CHESTPLATE);actor.core.inventory.setChestplate(ordinary);f.activate(actor);var fantasy=actor.core.inventory.getChestplate();assertEquals(Material.IRON_CHESTPLATE,fantasy.getType());assertTrue(fantasy.getItemMeta().isUnbreakable());var dropped=mock(org.bukkit.entity.Item.class);when(dropped.getItemStack()).thenReturn(fantasy);var event=new PlayerDropItemEvent(actor.player,dropped);f.dispatcher.accept(event);assertTrue(event.isCancelled());actor.core.inventory.setItemInOffHand(null);f.changed(actor);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(actor);assertEquals(ordinary,actor.core.inventory.getChestplate());assertFalse(Arrays.stream(actor.core.inventory.getContents()).filter(Objects::nonNull).anyMatch(item->new GearItems(f.plugin).gear(item)));actor.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(repository.load(old.id()).orElseThrow()));f.activate(actor);assertEquals(Material.IRON_CHESTPLATE,actor.core.inventory.getChestplate().getType());actor.core.inventory.setItemInOffHand(null);f.changed(actor);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(actor);assertEquals(ordinary,actor.core.inventory.getChestplate());}}
}
@@ -0,0 +1,6 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.ArgumentMatchers.*;import static org.mockito.Mockito.*;import games.dmg.fantasystick.persistence.*;import java.math.BigInteger;import java.nio.file.Path;import java.util.*;import org.bukkit.*;import org.bukkit.entity.*;import org.bukkit.event.inventory.InventoryClickEvent;import org.bukkit.event.player.PlayerInteractEntityEvent;import org.bukkit.inventory.EquipmentSlot;import org.bukkit.persistence.*;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
class FantasyArmorerNativeTest{
@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@SuppressWarnings("unchecked")@Test void adminPlacementDistinctNpcAndActiveShopPurchaseUseActualRegisteredPath()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");var console=mock(org.bukkit.command.CommandSender.class);when(console.hasPermission("fantasystick.admin")).thenReturn(true);var consoleMessages=new ArrayList<String>();doAnswer(call->{consoleMessages.add(call.getArgument(0));return null;}).when(console).sendMessage(anyString());UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);int x=actor.core.location.getBlockX()>>4,z=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(x);when(chunk.getZ()).thenReturn(z);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(x,z)).thenReturn(chunk);when(actor.world.isChunkLoaded(x,z)).thenReturn(true);when(actor.world.getEntities()).thenReturn(List.of());assertEquals(List.of("gear-price-copper","gear-price-diamond","gear-price-gold","gear-price-iron","gear-price-leather","gear-price-netherite","gear-price-variance-percent"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","gear-price-"}));assertEquals(List.of("gear-stock-copper-max","gear-stock-copper-min","gear-stock-diamond-max","gear-stock-diamond-min","gear-stock-gold-max","gear-stock-gold-min","gear-stock-iron-max","gear-stock-iron-min","gear-stock-leather-max","gear-stock-leather-min","gear-stock-netherite-max","gear-stock-netherite-min"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","gear-stock-"}));assertEquals(List.of("list","place","remove","restock"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"shop",""}));assertEquals(List.of("list","remove","restock"),f.command.tabComplete(console,"fantasystick",new String[]{"shop",""}));Runnable task;f.command.execute(console,"fantasystick",new String[]{"config","gear-price-variance-percent","0"});for(int attempt=0;attempt<10&&consoleMessages.stream().noneMatch(message->message.contains("Saved gear-price-variance-percent=0"));attempt++){task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}f.command.execute(console,"fantasystick",new String[]{"config","gear-stock-iron-min","20"});for(int attempt=0;attempt<10&&consoleMessages.stream().noneMatch(message->message.contains("Saved gear-stock-iron-min=20"));attempt++){task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}var villager=mock(Villager.class);when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap<String,String>();var pdc=mock(PersistentDataContainer.class);when(villager.getPersistentDataContainer()).thenReturn(pdc);doAnswer(call->{values.put(((NamespacedKey)call.getArgument(0)).getKey(),call.getArgument(2));return null;}).when(pdc).set(any(NamespacedKey.class),eq(PersistentDataType.STRING),anyString());when(pdc.get(any(NamespacedKey.class),eq(PersistentDataType.STRING))).thenAnswer(call->values.get(((NamespacedKey)call.getArgument(0)).getKey()));doAnswer(call->{((java.util.function.Consumer<Villager>)call.getArgument(2)).accept(villager);return villager;}).when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));f.command.execute(actor.player,"fantasystick",new String[]{"shop","place","Forge"});task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();var directory=(games.dmg.fantasystick.shop.FantasyArmorerDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"armorers");var shop=directory.all().getFirst();assertEquals("Forge",shop.name());assertEquals(List.of(shop.id().toString()),f.command.tabComplete(actor.player,"fantasystick",new String[]{"shop","remove",shop.id().toString().substring(0,8)}));var armorer=(games.dmg.fantasystick.shop.FantasyArmorerController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"armorerController");var registrar=(games.dmg.fantasystick.party.PartyRegistrarController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarController");assertTrue(armorer.tagged(villager));assertFalse(registrar.tagged(villager));when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[]{villager});armorer.reconcile(chunk);verify(actor.world,times(1)).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));verify(villager,times(2)).setAI(false);verify(villager,times(2)).setGravity(false);verify(villager,times(2)).setSilent(true);verify(villager,times(2)).setInvulnerable(true);verify(villager,times(2)).setCollidable(false);var damage=mock(org.bukkit.event.entity.EntityDamageEvent.class);when(damage.getEntity()Line truncated
}
@@ -60,8 +60,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
assertNotNull(fixture.packetListener);
assertEquals(11, fixture.timers.size(),
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects and session particles");
assertEquals(13, fixture.timers.size(),
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor-market/menu refresh and session particles");
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
@@ -1195,10 +1195,14 @@ class FantasyStickLifecycleNativeTest {
case "getLogger" -> Logger.getLogger("FantasyStick lifecycle fixture");
case "isPrimaryThread" -> Thread.currentThread() == main;
case "getOnlinePlayers" -> List.copyOf(players.values());
case "getWorlds" -> players.values().stream().map(org.bukkit.entity.Player::getWorld).distinct().toList();
case "getWorld" -> players.values().stream().map(org.bukkit.entity.Player::getWorld)
.filter(world -> java.util.Objects.equals(world.getUID(), args[0])).findFirst().orElse(null);
case "getPlayer" -> { assertSame(main, Thread.currentThread()); playerLookups++; yield players.get(args[0]); }
case "getPluginCommand" -> command;
case "createInventory" -> new org.bukkit.craftbukkit.inventory.CraftInventoryCustom(
(org.bukkit.inventory.InventoryHolder) args[0], (Integer) args[1], (net.kyori.adventure.text.Component) args[2]);
case "createInventory" -> args[1] instanceof Integer size
? new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], size, (net.kyori.adventure.text.Component) args[2])
: new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], (org.bukkit.event.inventory.InventoryType) args[1], (net.kyori.adventure.text.Component) args[2]);
case "addRecipe" -> {
order.add("recipe");
if ("recipe".equals(failRegistrationAt)) throw new IllegalStateException("Recipe registration failed");
@@ -1313,6 +1317,7 @@ class FantasyStickLifecycleNativeTest {
int inventoryCloses;
final List<Integer> particles = new ArrayList<>();
final List<org.bukkit.Sound> sounds = new ArrayList<>();
final List<net.kyori.adventure.title.Title> titles = new ArrayList<>();
final List<Object[]> resourcePacks = new ArrayList<>();
final java.util.Set<org.bukkit.NamespacedKey> knownRecipes = new java.util.LinkedHashSet<>();
final List<List<org.bukkit.NamespacedKey>> recipeAwards = new ArrayList<>();
@@ -1392,6 +1397,7 @@ class FantasyStickLifecycleNativeTest {
case "closeInventory" -> { inventoryCloses++; yield null; }
case "spawnParticle" -> { particles.add((Integer) args[2]); yield null; }
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
case "showTitle" -> { titles.add((net.kyori.adventure.title.Title)args[0]); yield null; }
case "addResourcePack" -> { resourcePacks.add(args.clone()); yield null; }
case "hasDiscoveredRecipe" -> knownRecipes.contains((org.bukkit.NamespacedKey) args[0]);
case "discoverRecipes" -> {
@@ -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());
}
}
@@ -11,7 +11,7 @@ class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()thr
@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"));}}
@@ -346,7 +346,7 @@ class LongbowNativeTest {
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "longbow"));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(java.util.List.of("config", "experience", "give", "inspect", "recover"),
assertEquals(java.util.List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(java.util.List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"c"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown"}).isEmpty());
@@ -117,7 +117,7 @@ class MendingLightNativeTest {
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");
"Persistent character membership follows the transferred sheet while support itself creates no unit");
}
}
@@ -20,232 +20,14 @@ class PartyBookNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookCreatesPlayerOwnedPartyThroughNativeCustomClickWithoutInventoryChanges() throws Exception {
@Test void partyPagesAreInformationalAndDirectPlayersToRegistrarManagement() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
var before = a.core.snapshot();
open(f, a);
var create = button(a, "[Create party]");
click(f, a, create);
var party = PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).orElseThrow();
assertEquals(a.core.id, party.leader());
assertEquals(java.util.List.of(a.core.id), party.members());
click(f, a, create); // The old page cannot perform the action twice.
assertEquals(party, PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).orElseThrow());
assertArrayEquals(before, a.core.snapshot());
}
}
@Test void memberBookLeavePreservesPartyButLeaderBookLeaveDisbandsWithoutSuccession() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
f.activate(leader); f.activate(member);
var parties = PartyLifecycleNativeTest.parties(f);
var party = parties.create(leader.core.id).orElseThrow();
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
open(f, member); click(f, member, button(member, "[Leave party]"));
assertTrue(parties.partyOf(member.core.id).isEmpty());
assertEquals(party.id(), parties.partyOf(leader.core.id).orElseThrow().id());
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
open(f, leader); click(f, leader, button(leader, "[Leave party]"));
assertTrue(parties.partyOf(leader.core.id).isEmpty());
assertTrue(parties.partyOf(member.core.id).isEmpty());
}
}
@Test void nativeBookInvitesRequireAcceptanceDisplayCharacterNamesAndAllowLeaderKick() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
f.activate(leader); f.activate(member);
var parties = PartyLifecycleNativeTest.parties(f);
open(f, leader); click(f, leader, button(leader, "[Create party]")); drain(f);
click(f, leader, button(leader, "[Invite players]"));
assertTrue(text(leader).contains(member.core.record.name()));
click(f, leader, button(leader, "[Invite]"));
assertTrue(parties.partyOf(member.core.id).isEmpty());
assertTrue(member.messages.stream().anyMatch(message -> message.contains("invitation")));
open(f, member); click(f, member, button(member, "[Invitations]"));
assertTrue(text(member).contains(leader.core.record.name()));
click(f, member, button(member, "[Accept]")); drain(f);
assertEquals(parties.partyOf(leader.core.id), parties.partyOf(member.core.id));
assertTrue(text(member).contains(leader.core.record.name()));
assertTrue(text(member).contains(member.core.record.name()));
open(f, leader); click(f, leader, button(leader, "[Kick]"));
assertTrue(parties.partyOf(member.core.id).isEmpty());
assertEquals(java.util.List.of(leader.core.id), parties.partyOf(leader.core.id).orElseThrow().members());
drain(f); click(f, leader, button(leader, "[Disband party]"));
assertTrue(parties.partyOf(leader.core.id).isEmpty());
}
}
@Test void copiedReplacedMalformedAndWrongNamespaceActionsCannotCreateParties() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
open(f, a); var old = button(a, "[Create party]");
open(f, b); click(f, b, old);
assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(b.core.id).isEmpty());
open(f, a); var current = button(a, "[Create party]");
click(f, a, old);
for (var payload : java.util.List.of(net.minecraft.nbt.StringTag.valueOf("x".repeat(1000)),
net.minecraft.nbt.StringTag.valueOf("not-a-token"), new net.minecraft.nbt.CompoundTag())) {
click(f, a, new net.minecraft.network.chat.ClickEvent.Custom(current.id(), java.util.Optional.of(payload)));
}
click(f, a, new net.minecraft.network.chat.ClickEvent.Custom(
net.minecraft.resources.Identifier.fromNamespaceAndPath("other", "party_book"), current.payload()));
assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).isEmpty());
click(f, a, current);
assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).isPresent(), "Invalid packets must not consume the legitimate view");
}
}
@Test void changedSlotOrEndedCharacterCannotUseOldPartyControlsEvenAfterReturningToBookSlot() throws Exception {
for (int route = 0; route < 3; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("route-" + route))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("route-" + route));
f.players.put(a.core.id, a.player); f.activate(a);
var parties = PartyLifecycleNativeTest.parties(f);
open(f, a); var action = button(a, "[Create party]");
if (route == 0) a.core.inventory.getInventory().setSelectedSlot(1);
else if (route == 1) { a.core.inventory.setItemInOffHand(null); f.changed(a); }
else { f.plugin.setEnabled(false); f.awaitWorkers(); }
click(f, a, action);
a.core.inventory.getInventory().setSelectedSlot(0);
click(f, a, action);
assertTrue(parties.partyOf(a.core.id).isEmpty());
}
}
@Test void managementRechecksPartyIdentityRatherThanDisbandingAReplacementFromAnOldPage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
var parties = PartyLifecycleNativeTest.parties(f);
var previous = parties.create(a.core.id).orElseThrow();
open(f, a); var old = button(a, "[Disband party]");
assertTrue(parties.leave(a.core.id, previous.id()));
var replacement = parties.create(a.core.id).orElseThrow();
click(f, a, old);
assertEquals(replacement, parties.partyOf(a.core.id).orElseThrow());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Party action unavailable")));
}
}
@Test void inviteChoicesArePagedAndMovingPagesRevokesTheOldPagesActions() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
var candidates = new java.util.ArrayList<UUID>();
for (int i = 0; i < 101; i++) {
var id = UUID.randomUUID(); candidates.add(id);
var player = mock(org.bukkit.entity.Player.class);
when(player.getUniqueId()).thenReturn(id); when(player.isOnline()).thenReturn(true);
when(player.getName()).thenReturn("Candidate " + i); f.players.put(id, player);
}
var parties = PartyLifecycleNativeTest.parties(f);
parties.create(a.core.id).orElseThrow();
open(f, a); click(f, a, button(a, "[Invite players]"));
var old = button(a, "[Invite]");
assertTrue(((org.bukkit.inventory.meta.BookMeta) a.books.getLast().getItemMeta()).getPageCount() <= 100);
click(f, a, button(a, "[Next]")); click(f, a, old);
assertTrue(candidates.stream().allMatch(id -> parties.invitationsFor(id).isEmpty()));
click(f, a, button(a, "[Invite]"));
assertEquals(1, candidates.stream().filter(id -> !parties.invitationsFor(id).isEmpty()).count());
}
}
@Test void anInvitationAlreadyRenderedInTheBookCannotBeAcceptedAtItsRealTimeDeadline() throws Exception {
var now = new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.EPOCH);
var clock = new java.time.Clock() {
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
@Override public java.time.Clock withZone(java.time.ZoneId zone) { return this; }
@Override public java.time.Instant instant() { return now.get(); }
};
try (var clocks = mockStatic(java.time.Clock.class, CALLS_REAL_METHODS)) {
clocks.when(java.time.Clock::systemUTC).thenReturn(clock);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
f.activate(leader); f.activate(member);
var parties = PartyLifecycleNativeTest.parties(f);
var group = parties.create(leader.core.id).orElseThrow();
parties.invite(leader.core.id, group.id(), member.core.id).orElseThrow();
open(f, member); click(f, member, button(member, "[Invitations]"));
var accept = button(member, "[Accept]");
now.set(java.time.Instant.EPOCH.plusSeconds(60));
click(f, member, accept);
assertTrue(parties.partyOf(member.core.id).isEmpty());
assertTrue(parties.invitationsFor(member.core.id).isEmpty());
}
}
}
@Test void anOpenAcceptancePageCannotOverfillAPartyThatFilledAfterRendering() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
f.activate(leader); f.activate(member);
var parties = PartyLifecycleNativeTest.parties(f);
var group = parties.create(leader.core.id).orElseThrow();
parties.invite(leader.core.id, group.id(), member.core.id).orElseThrow();
open(f, member); click(f, member, button(member, "[Invitations]"));
var accept = button(member, "[Accept]");
for (int i = 0; i < 5; i++) {
UUID id = UUID.randomUUID();
var player = mock(org.bukkit.entity.Player.class);
when(player.getUniqueId()).thenReturn(id); when(player.isOnline()).thenReturn(true);
f.players.put(id, player);
assertTrue(parties.accept(id, parties.invite(leader.core.id, group.id(), id).orElseThrow().id()));
}
click(f, member, accept);
assertTrue(parties.partyOf(member.core.id).isEmpty());
assertEquals(6, parties.partyOf(leader.core.id).orElseThrow().members().size());
}
}
@Test void sheetTransferAndCharacterSwitchKeepMembershipOnThePlayerAndInvalidateOldBookActions() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
var otherSheet = b.core.inventory.getItemInOffHand().clone();
f.activate(a);
var parties = PartyLifecycleNativeTest.parties(f);
var party = parties.create(a.core.id).orElseThrow();
open(f, a); var oldDisband = button(a, "[Disband party]");
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a);
f.awaitCharacterAndReceipt(a);
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
open(f, b);
assertTrue(text(b).contains(a.core.record.name()));
assertTrue(parties.partyOf(b.core.id).isEmpty(), "A traded sheet must not transfer party membership");
click(f, b, oldDisband);
a.core.inventory.setItemInOffHand(otherSheet); f.activate(a);
open(f, a);
assertTrue(text(a).contains(b.core.record.name()));
assertEquals(party.id(), parties.partyOf(a.core.id).orElseThrow().id());
click(f, a, oldDisband);
assertEquals(party.id(), parties.partyOf(a.core.id).orElseThrow().id());
f.plugin.setEnabled(true); var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); f.activate(actor); open(f, actor);
String empty = text(actor); assertTrue(empty.contains("No party")); assertTrue(empty.contains("Party\nRegistrar"));
var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(actor.core.id).orElseThrow();var directory=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");UUID inactive=UUID.randomUUID();var invitation=directory.invite(actor.core.record.id(),party.id(),inactive).join().invitation().orElseThrow();assertEquals(games.dmg.fantasystick.party.CharacterPartyDirectory.Status.APPLIED,directory.accept(inactive,invitation.id()).join().status());
open(f,actor);String joined=text(actor);assertTrue(joined.contains("2 members\n1 active"));assertTrue(joined.contains(inactive.toString().substring(0,8)));assertTrue(joined.contains("Manage this party"));
assertFalse(joined.contains("[Leave party]"));assertFalse(joined.contains("[Disband party]"));
}
}
@@ -263,6 +45,8 @@ class PartyBookNativeTest {
static void drain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
((games.dmg.fantasystick.party.CharacterPartyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characterParties"))
.defeat(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
Runnable task;
while ((task = f.tasks.poll()) != null) task.run();
}
@@ -1,7 +1,8 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.Path;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class PartyCapacityNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void registeredPolicyDrivesActualBookInvitationsAcceptanceGrandfatheringAndRestart()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(java.util.List.of("party-max-size"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party-m"}));assertEquals(java.util.List.of("6"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party-max-size",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=6"));for(String bad:java.util.List.of("0","-1","1.5","bad","2147483648")){f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size",bad});assertTrue(sender.messages.getLast().contains("invalid"));}sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2","extra"});assertTrue(sender.messages.getLast().contains("Usage"));save(f,sender,"2");var leader=new FantasyStickLifecycleNativeTest.Actor(data);var member=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(leader.core.id,leader.player);f.players.put(member.core.id,member.player);f.activate(leader);f.activate(member);PartyBookNativeTest.open(f,leader);PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Create party]"));PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite players]"));PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite]"));PartyBookNativeTest.open(f,member);PartyBookNativeTest.click(f,member,PartyBookNativeTest.button(member,"[Invitations]"));PartyBookNativeTest.click(f,member,PartyBookNativeTest.button(member,"[Accept]"));PartyBookNativeTest.drain(f);var parties=PartyLifecycleNativeTest.parties(f);assertEquals(2,parties.partyOf(leader.core.id).orElseThrow().members().size());assertTrue(PartyBookNativeTest.text(member).contains("2/2 members"));PartyBookNativeTest.open(f,leader);assertFalse(PartyBookNativeTest.text(leader).contains("[Invite players]"));save(f,sender,"3");var third=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(third.core.id,third.player);f.activate(third);PartyBookNativeTest.open(f,leader);assertTrue(PartyBookNativeTest.text(leader).contains("2/3 members"));PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite players]"));PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite]"));assertEquals(1,parties.invitationsFor(third.core.id).size());save(f,sender,"2");PartyBookNativeTest.open(f,third);PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Invitations]"));PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Accept]"));PartyBookNativeTest.drain(f);assertTrue(parties.partyOf(third.core.id).isEmpty());assertEquals(2,parties.partyOf(leader.core.id).orElseThrow().members().size());save(f,sender,"3");PartyBookNativeTest.open(f,third);PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Invitations]"));PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Accept]"));PartyBookNativeTest.drain(f);assertEquals(3,parties.partyOf(leader.core.id).orElseThrow().members().size());save(f,sender,"2");assertEquals(3,parties.partyOf(leader.core.id).orElseThrow().members().size());assertTrue(PartyCombatNativeTest.hit(f,leader.player,member.player).isCancelled());PartyBookNativeTest.open(f,leader);assertTrue(PartyBookNativeTest.text(leader).contains("3/2 members"));assertFalse(PartyBookNativeTest.text(leader).contains("[Invite players]"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=2"));assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(leader.core.id).isEmpty());}}
@Test void failedRegisteredSaveLeavesCurrentRosterAndLimitUnchanged()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var leader=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(leader.core.id,leader.player);var party=PartyLifecycleNativeTest.parties(f).create(leader.core.id).orElseThrow();java.nio.file.Files.createDirectory(data.resolve("config.yml"));f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"));assertEquals(party,PartyLifecycleNativeTest.parties(f).partyOf(leader.core.id).orElseThrow());f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=6"));}}
@Test void registeredPolicyChecksAcceptanceGrandfathersRosterAndSurvivesRestart()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(java.util.List.of("party-max-size"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party-m"}));for(String bad:java.util.List.of("0","-1","1.5","bad","2147483648")){f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size",bad});assertTrue(sender.messages.getLast().contains("invalid"));}save(f,sender,"2");var leader=actor(f,data);var member=actor(f,data);var third=actor(f,data);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(leader.core.id).orElseThrow();var first=parties.invite(leader.core.id,party.id(),member.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,first.id()));var second=parties.invite(leader.core.id,party.id(),third.core.id).orElseThrow();assertFalse(parties.accept(third.core.id,second.id()));save(f,sender,"3");assertTrue(parties.accept(third.core.id,second.id()));save(f,sender,"2");assertEquals(3,parties.partyOf(leader.core.id).orElseThrow().members().size());f.plugin.setEnabled(false);f.plugin.setEnabled(true);var directory=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");assertEquals(3,directory.partyById(party.id()).orElseThrow().members().size());}}
@Test void failedRegisteredSaveLeavesCurrentRosterAndLimitUnchanged()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var leader=actor(f,data);var party=PartyLifecycleNativeTest.parties(f).create(leader.core.id).orElseThrow();java.nio.file.Files.createDirectory(data.resolve("config.yml"));f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"));assertEquals(party.id(),PartyLifecycleNativeTest.parties(f).partyOf(leader.core.id).orElseThrow().id());f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=6"));}}
static FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture f,Path data)throws Exception{var value=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(value.core.id,value.player);f.activate(value);return value;}
static void save(FantasyStickLifecycleNativeTest.Fixture f,EquipmentAdminCommandNativeTest.Sender sender,String value)throws Exception{int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size",value});assertEquals(before,sender.messages.size());while(sender.messages.size()==before){var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}assertTrue(sender.messages.getLast().contains("="+value));}
}
@@ -30,13 +30,13 @@ class PartyCombatNativeTest {
accept(f, b);
assertTrue(hit(f, a.player, b.player).isCancelled(), "Book acceptance must protect native combat");
assertTrue(hit(f, b.player, a.player).isCancelled());
PartyBookNativeTest.open(f, b); action(f, b, "Leave party");
leave(f,b);
assertFalse(hit(f, a.player, b.player).isCancelled());
invite(f, a); accept(f, b);
PartyBookNativeTest.open(f, a); action(f, a, "Kick");
kick(f,a,b);
assertFalse(hit(f, a.player, b.player).isCancelled());
invite(f, a); accept(f, b);
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
disband(f,a);
assertFalse(hit(f, a.player, b.player).isCancelled());
}
}
@@ -77,7 +77,7 @@ class PartyCombatNativeTest {
assertFalse(hit(f, arrow, b.player).isCancelled());
createAndInvite(f, a, b); accept(f, b);
assertTrue(hit(f, arrow, b.player).isCancelled(), "Native indirect attribution must use current party");
PartyBookNativeTest.open(f, b); action(f, b, "Leave party");
leave(f,b);
assertFalse(hit(f, arrow, b.player).isCancelled());
var sheet = a.core.inventory.getItemInOffHand().clone();
exit(f, a);
@@ -107,7 +107,7 @@ class PartyCombatNativeTest {
for (var source : java.util.List.of(tnt, fangs, cloud, pet, firework)) {
assertTrue(hit(f, source, b.player).isCancelled(), source.getClass().toString());
}
PartyBookNativeTest.open(f, b); action(f, b, "Leave party");
leave(f,b);
for (var source : java.util.List.of(tnt, fangs, cloud, pet, firework)) assertFalse(hit(f, source, b.player).isCancelled());
exit(f, a);
for (var source : java.util.List.of(tnt, fangs, cloud, pet, firework)) assertTrue(hit(f, source, b.player).isCancelled());
@@ -148,13 +148,13 @@ class PartyCombatNativeTest {
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
f.dispatcher.accept(remove); effects.get(b.player).remove(type);
assertFalse(tickDamage(f, b, cause).isCancelled(), "Removed attribution must not protect unrelated later damage");
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
disband(f,a);
assertFalse(apply(f, b, null, effect, a.player).isCancelled());
// An unrelated mob/environmental replacement clears only this effect's provenance.
assertFalse(apply(f, b, effect, new org.bukkit.potion.PotionEffect(type, 300, 1), null).isCancelled());
createAndInvite(f, a, b); accept(f, b);
assertFalse(tickDamage(f, b, cause).isCancelled());
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
disband(f,a);
}
}
}
@@ -219,7 +219,7 @@ class PartyCombatNativeTest {
for (var pulse : f.pulses) pulse.run();
assertFalse(b.core.pdc.getKeys().stream().anyMatch(key -> key.getKey().startsWith("combat_dot_")));
assertFalse(tickDamage(f, b, DamageCause.POISON).isCancelled());
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
disband(f,a);
f.dispatcher.accept(new org.bukkit.event.entity.EntityCombustByEntityEvent(a.player, b.player, 2.0f));
fires.put(b.player, 40);
createAndInvite(f, a, b); accept(f, b);
@@ -408,18 +408,21 @@ class PartyCombatNativeTest {
}
static void createAndInvite(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor a, FantasyStickLifecycleNativeTest.Actor b) throws Exception {
PartyBookNativeTest.open(f, a); action(f, a, "Create party"); invite(f, a);
var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();
assertTrue(parties.invite(a.core.id,party.id(),b.core.id).isPresent());
}
static void invite(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a) throws Exception {
PartyBookNativeTest.open(f, a); action(f, a, "Invite players"); action(f, a, "Invite");
var parties=PartyLifecycleNativeTest.parties(f);var party=parties.partyOf(a.core.id).orElseThrow();
var target=f.players.values().stream().filter(player->!player.getUniqueId().equals(a.core.id)&&parties.partyOf(player.getUniqueId()).isEmpty()).findFirst().orElseThrow();
assertTrue(parties.invite(a.core.id,party.id(),target.getUniqueId()).isPresent());
}
static void accept(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor b) throws Exception {
PartyBookNativeTest.open(f, b); action(f, b, "Invitations"); action(f, b, "Accept");
}
static void action(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a, String label) throws Exception {
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[" + label + "]"));
PartyBookNativeTest.drain(f);
var parties=PartyLifecycleNativeTest.parties(f);var invitation=parties.invitationsFor(b.core.id).getFirst();
assertTrue(parties.accept(b.core.id,invitation.id()),"accept failed for "+invitation+" active="+parties.partyOf(b.core.id));
}
static void leave(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor){var parties=PartyLifecycleNativeTest.parties(f);var party=parties.partyOf(actor.core.id).orElseThrow();assertTrue(parties.leave(actor.core.id,party.id()));}
static void kick(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor leader,FantasyStickLifecycleNativeTest.Actor member){var parties=PartyLifecycleNativeTest.parties(f);var party=parties.partyOf(leader.core.id).orElseThrow();assertTrue(parties.kick(leader.core.id,party.id(),member.core.id));}
static void disband(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor leader){var parties=PartyLifecycleNativeTest.parties(f);var party=parties.partyOf(leader.core.id).orElseThrow();assertTrue(parties.disband(leader.core.id,party.id()));}
static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, Entity source, Entity target) {
var damage = mock(DamageSource.class);
when(damage.getCausingEntity()).thenReturn(source);
@@ -1,95 +1,13 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.party.PartyRoster;
import java.nio.file.Path;
import net.kyori.adventure.text.Component;
import org.bukkit.event.player.PlayerQuitEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Packaged plugin startup and registered native lifecycle events; not proof of book/effect controls. */
class PartyLifecycleNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredQuitHandlesLastMemberEvenWhileNativeOnlineFlagIsStillTrue() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
var parties = parties(f);
var party = parties.create(leader.core.id).orElseThrow();
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
f.dispatcher.accept(new PlayerQuitEvent(leader.player, Component.empty()));
leader.core.online = false;
assertEquals(leader.core.id, parties.partyOf(member.core.id).orElseThrow().leader());
f.dispatcher.accept(new PlayerQuitEvent(member.player, Component.empty()));
assertTrue(parties.partyOf(member.core.id).isEmpty());
assertTrue(parties.partyOf(leader.core.id).isEmpty());
}
}
@Test void disablingAndReenablingDisbandsAndCannotReuseOldInvitationsOrServices() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
var old = parties(f);
var party = old.create(leader.core.id).orElseThrow();
var invitation = old.invite(leader.core.id, party.id(), member.core.id).orElseThrow();
f.plugin.setEnabled(false); f.awaitWorkers();
assertTrue(old.partyOf(leader.core.id).isEmpty());
assertFalse(old.accept(member.core.id, invitation.id()));
assertTrue(old.create(leader.core.id).isEmpty());
f.plugin.setEnabled(true);
var fresh = parties(f);
assertNotSame(old, fresh);
assertTrue(fresh.partyOf(leader.core.id).isEmpty());
assertFalse(fresh.accept(member.core.id, invitation.id()));
assertNotEquals(party.id(), fresh.create(leader.core.id).orElseThrow().id());
}
}
@Test void consecutiveQuitEventsAreAuthoritativeEvenIfBothOnlineFlagsLag() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
var parties = parties(f);
var party = parties.create(leader.core.id).orElseThrow();
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
f.dispatcher.accept(new PlayerQuitEvent(leader.player, Component.empty()));
assertEquals(leader.core.id, parties.partyOf(member.core.id).orElseThrow().leader());
f.dispatcher.accept(new PlayerQuitEvent(member.player, Component.empty()));
assertTrue(parties.partyOf(leader.core.id).isEmpty());
assertTrue(parties.partyOf(member.core.id).isEmpty());
}
}
@Test void rejoiningBeforeTheOtherMemberQuitsRetainsTheSamePartyAndLeader() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
var parties = parties(f);
var party = parties.create(leader.core.id).orElseThrow();
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
f.dispatcher.accept(new PlayerQuitEvent(leader.player, Component.empty()));
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(leader.player, Component.empty()));
f.dispatcher.accept(new PlayerQuitEvent(member.player, Component.empty()));
assertEquals(party.id(), parties.partyOf(leader.core.id).orElseThrow().id());
assertEquals(leader.core.id, parties.partyOf(leader.core.id).orElseThrow().leader());
}
}
static PartyRoster parties(FantasyStickLifecycleNativeTest.Fixture f) {
return (PartyRoster) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "parties");
}
import games.dmg.fantasystick.party.PartyRoster;import java.nio.file.Path;import net.kyori.adventure.text.Component;import org.bukkit.event.player.PlayerQuitEvent;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
/** Persistent character parties survive player and plugin lifecycle boundaries. */
class PartyLifecycleNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void quitRetainsCharacterMembershipAndLeadership()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var leader=actor(f);var member=actor(f);var parties=parties(f);var party=parties.create(leader.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,parties.invite(leader.core.id,party.id(),member.core.id).orElseThrow().id()));f.dispatcher.accept(new PlayerQuitEvent(leader.player,Component.empty()));leader.core.online=false;var directory=directory(f);var saved=directory.partyById(party.id()).orElseThrow();assertEquals(leader.core.record.id(),saved.leader());assertTrue(saved.members().containsAll(java.util.Set.of(leader.core.record.id(),member.core.record.id())));}}
@Test void restartPreservesPartyAndIndefiniteInvitation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var leader=actor(f);var member=actor(f);var old=parties(f);var party=old.create(leader.core.id).orElseThrow();var invitation=old.invite(leader.core.id,party.id(),member.core.id).orElseThrow();f.plugin.setEnabled(false);f.awaitWorkers();f.plugin.setEnabled(true);var fresh=directory(f);assertEquals(party.id(),fresh.partyOf(leader.core.record.id()).orElseThrow().id());assertEquals(invitation.id(),fresh.invitationsFor(member.core.record.id()).getFirst().id());assertEquals(games.dmg.fantasystick.party.CharacterPartyDirectory.Status.APPLIED,fresh.accept(member.core.record.id(),invitation.id()).join().status());assertEquals(party.id(),fresh.partyOf(member.core.record.id()).orElseThrow().id());}}
@Test void durableDisbandNotifiesOnlyCurrentlyActiveFormerMembersOnce()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var leader=actor(f);var member=actor(f);var parties=parties(f);var party=parties.create(leader.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,parties.invite(leader.core.id,party.id(),member.core.id).orElseThrow().id()));var inactive=java.util.UUID.randomUUID();var pending=directory(f).invite(leader.core.record.id(),party.id(),inactive).join().invitation().orElseThrow();directory(f).accept(inactive,pending.id()).join();assertTrue(parties.disband(leader.core.id,party.id()));PartyBookNativeTest.drain(f);assertEquals(1,leader.titles.size());assertEquals(1,member.titles.size());assertTrue(leader.titles.getFirst().title().toString().contains("Party ended"));assertFalse(parties.disband(leader.core.id,party.id()));PartyBookNativeTest.drain(f);assertEquals(1,leader.titles.size());assertEquals(1,member.titles.size());}}
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{var value=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(value.core.id,value.player);f.activate(value);return value;}
static PartyRoster parties(FantasyStickLifecycleNativeTest.Fixture f){return(PartyRoster)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"parties");}
static games.dmg.fantasystick.party.CharacterPartyDirectory directory(FantasyStickLifecycleNativeTest.Fixture f){return(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");}
}
@@ -0,0 +1,63 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.*;
import org.bukkit.*;
import org.bukkit.entity.Villager;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
class PartyRegistrarNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void startupReconcilesDeadMembershipAndInvitationsBeforePartyAuthorityResumes()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
var deadLeader=new FantasyStickLifecycleNativeTest.Actor(data);var liveLeader=new FantasyStickLifecycleNativeTest.Actor(data);var deadInvitee=new FantasyStickLifecycleNativeTest.Actor(data);var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);try(var storage=new games.dmg.fantasystick.persistence.CharacterStorage(repository,java.time.Clock.systemUTC())){storage.recordDeath(deadLeader.core.record.id(),false).join();storage.recordDeath(deadInvitee.core.record.id(),false).join();}try(var directory=new games.dmg.fantasystick.party.CharacterPartyDirectory(new games.dmg.fantasystick.persistence.YamlPartyRepository(data),()->6)){directory.create(deadLeader.core.record.id(),"Ended").join();var surviving=directory.create(liveLeader.core.record.id(),"Surviving").join().party().orElseThrow();directory.invite(liveLeader.core.record.id(),surviving.id(),deadInvitee.core.record.id()).join();}
f.plugin.setEnabled(true);var parties=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");parties.readiness().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(parties.partyOf(deadLeader.core.record.id()).isEmpty());assertTrue(parties.invitationsFor(deadInvitee.core.record.id()).isEmpty());assertTrue(parties.ready());
}
}
@Test void chargedLeadershipPlanCompletesExactlyOnceAfterRestart()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
var actor=new FantasyStickLifecycleNativeTest.Actor(data);var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);var old=repository.load(actor.core.record.id()).orElseThrow();var funded=new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(20),true,old.rewardReceipts());repository.replace(funded,old.revision());UUID target=new FantasyStickLifecycleNativeTest.Actor(data).core.record.id(),partyId=UUID.randomUUID();try(var directory=new games.dmg.fantasystick.party.CharacterPartyDirectory(new games.dmg.fantasystick.persistence.YamlPartyRepository(data),()->6)){directory.create(partyId,funded.id(),"Transferable").join();var invitation=directory.invite(funded.id(),partyId,target).join().invitation().orElseThrow();directory.accept(target,invitation.id()).join();}UUID operation=UUID.randomUUID();var plan=new games.dmg.fantasystick.persistence.PartyOperationStore.Plan(operation,games.dmg.fantasystick.persistence.PartyOperationStore.Kind.TRANSFER,funded.id(),funded.revision(),BigInteger.TEN,null,partyId,target,new games.dmg.fantasystick.persistence.YamlPartyRepository(data).load().stream().filter(value->value.id().equals(partyId)).findFirst().orElseThrow().revision());try(var plans=new games.dmg.fantasystick.persistence.PartyOperationStore(data)){plans.prepare(plan).toCompletableFuture().join();}try(var storage=new games.dmg.fantasystick.persistence.CharacterStorage(repository,java.time.Clock.systemUTC())){assertEquals(games.dmg.fantasystick.persistence.CharacterStorage.CoinSpendStatus.APPLIED,storage.spendCoins(funded.id(),funded.revision(),operation,BigInteger.TEN).join().status());}
f.plugin.setEnabled(true);var management=(games.dmg.fantasystick.party.PartyManagement)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyManagement");var plans=(games.dmg.fantasystick.persistence.PartyOperationStore)FantasyStickLifecycleNativeTest.Fixture.field(management,"operations");plans.load().toCompletableFuture().join();var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");characters.find(UUID.randomUUID()).join();var parties=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");parties.defeat(UUID.randomUUID()).join();plans.load().toCompletableFuture().join();assertEquals(target,parties.partyOf(funded.id()).orElseThrow().leader());assertEquals(BigInteger.TEN,characters.find(funded.id()).join().orElseThrow().coins());assertTrue(plans.load().toCompletableFuture().join().isEmpty());
}
}
@Test void chargedRegistrationPlanCompletesExactlyOnceAfterRestart()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
var actor=new FantasyStickLifecycleNativeTest.Actor(data);var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);var old=repository.load(actor.core.record.id()).orElseThrow();var funded=new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(20),true,old.rewardReceipts());repository.replace(funded,old.revision());UUID operation=UUID.randomUUID();var plan=new games.dmg.fantasystick.persistence.PartyOperationStore.Plan(operation,games.dmg.fantasystick.persistence.PartyOperationStore.Kind.CREATE,funded.id(),funded.revision(),BigInteger.TEN,"Recovered",null,null,null);try(var plans=new games.dmg.fantasystick.persistence.PartyOperationStore(data)){plans.prepare(plan).toCompletableFuture().join();}try(var storage=new games.dmg.fantasystick.persistence.CharacterStorage(repository,java.time.Clock.systemUTC())){assertEquals(games.dmg.fantasystick.persistence.CharacterStorage.CoinSpendStatus.APPLIED,storage.spendCoins(funded.id(),funded.revision(),operation,BigInteger.TEN).join().status());}
f.plugin.setEnabled(true);var management=(games.dmg.fantasystick.party.PartyManagement)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyManagement");var plans=(games.dmg.fantasystick.persistence.PartyOperationStore)FantasyStickLifecycleNativeTest.Fixture.field(management,"operations");plans.load().toCompletableFuture().join();var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");characters.find(UUID.randomUUID()).join();var parties=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");parties.defeat(UUID.randomUUID()).join();plans.load().toCompletableFuture().join();assertEquals(operation,parties.partyOf(funded.id()).orElseThrow().id());assertEquals(BigInteger.TEN,characters.find(funded.id()).join().orElseThrow().coins());assertTrue(plans.load().toCompletableFuture().join().isEmpty());
}
}
@SuppressWarnings("unchecked")
@Test void adminPlacementReconcilesProtectedVillagerAndActiveCharacterCanPayToRegister()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);
int chunkX=actor.core.location.getBlockX()>>4,chunkZ=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(chunkX);when(chunk.getZ()).thenReturn(chunkZ);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(chunkX,chunkZ)).thenReturn(chunk);when(actor.world.getChunkAt(any(Location.class))).thenReturn(chunk);when(actor.world.isChunkLoaded(chunkX,chunkZ)).thenReturn(true);when(actor.world.getEntities()).thenReturn(List.of());
var villager=mock(Villager.class);when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap<String,String>();var pdc=mock(PersistentDataContainer.class);when(villager.getPersistentDataContainer()).thenReturn(pdc);doAnswer(call->{values.put(((NamespacedKey)call.getArgument(0)).getKey(),call.getArgument(2));return null;}).when(pdc).set(any(NamespacedKey.class),eq(PersistentDataType.STRING),anyString());when(pdc.get(any(NamespacedKey.class),eq(PersistentDataType.STRING))).thenAnswer(call->values.get(((NamespacedKey)call.getArgument(0)).getKey()));
doAnswer(call->{((java.util.function.Consumer<Villager>)call.getArgument(2)).accept(villager);return villager;}).when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));
f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","place","Central"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();verify(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));verify(villager).setAI(false);verify(villager).setInvulnerable(true);
var directory=(games.dmg.fantasystick.party.PartyRegistrarDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrars");var registrar=directory.all().getFirst();assertEquals("Central",registrar.name());
var old=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(20),true,old.rewardReceipts()),old.revision());f.activate(actor);var management=(games.dmg.fantasystick.party.PartyManagement)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyManagement");management.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(management.active(actor.player,registrar.id()).isPresent());var controller=(games.dmg.fantasystick.party.PartyRegistrarController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarController");assertTrue(controller.tagged(villager));
int openMenus=actor.inventories.size(),openBooks=actor.books.size();var mainInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);f.dispatcher.accept(mainInteraction);var offhandDuplicate=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.OFF_HAND);f.dispatcher.accept(offhandDuplicate);assertTrue(mainInteraction.isCancelled());assertTrue(offhandDuplicate.isCancelled());assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size());assertEquals(Material.WRITABLE_BOOK,actor.inventories.getLast().getItem(11).getType());var vetoedInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);vetoedInteraction.setCancelled(true);f.dispatcher.accept(vetoedInteraction);assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size());
var menus=(games.dmg.fantasystick.party.PartyRegistrarMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarMenus");var root=actor.inventories.getLast();var openName=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(openName.getInventory()).thenReturn(root);when(openName.getWhoClicked()).thenReturn(actor.player);when(openName.getCurrentItem()).thenReturn(root.getItem(11));menus.click(openName);var anvil=actor.inventories.getLast();var submit=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(submit.getInventory()).thenReturn(anvil);when(submit.getWhoClicked()).thenReturn(actor.player);when(submit.getRawSlot()).thenReturn(2);var anvilView=mock(org.bukkit.inventory.view.AnvilView.class);when(anvilView.getRenameText()).thenReturn(" Cafe\u0301 Heroes ");when(submit.getView()).thenReturn(anvilView);menus.click(submit);var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");characters.find(UUID.randomUUID()).join();var parties=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals("Café Heroes",parties.partyOf(old.id()).orElseThrow().displayName());assertEquals(BigInteger.TEN,characters.find(old.id()).join().orElseThrow().coins());
var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var leaderRoot=actor.inventories.getLast();click(menus,actor.player,leaderRoot,10,false);var eligible=actor.inventories.getLast();click(menus,actor.player,eligible,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();click(menus,actor.player,actor.inventories.getLast(),22,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.invitationsFor(member.core.record.id()).isEmpty());click(menus,actor.player,actor.inventories.getLast(),10,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));var memberRoot=member.inventories.getLast();click(menus,member.player,memberRoot,15,false);var invitationMenu=member.inventories.getLast();click(menus,member.player,invitationMenu,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isPresent());controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);click(menus,actor.player,actor.inventories.getLast(),0,true);characters.find(UUID.randomUUID()).join();parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(member.core.record.id(),parties.partyOf(old.id()).orElseThrow().leader());assertEquals(BigInteger.ZERO,characters.find(old.id()).join().orElseThrow().coins());
f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","list"});assertTrue(actor.messages.stream().anyMatch(message->message.contains(registrar.id().toString())&&message.contains("Central")));when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[]{villager});controller.reconcile(chunk);verify(actor.world,times(1)).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));var damage=new org.bukkit.event.entity.EntityDamageEvent(villager,org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE,1);controller.damage(damage);assertTrue(damage.isCancelled());var combust=new org.bukkit.event.entity.EntityCombustEvent(villager,5);controller.combust(combust);assertTrue(combust.isCancelled());var interaction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);controller.interact(interaction);assertTrue(interaction.isCancelled());var leash=new org.bukkit.event.entity.PlayerLeashEntityEvent(villager,actor.player,actor.player,EquipmentSlot.HAND);controller.leash(leash);assertTrue(leash.isCancelled());
when(actor.world.getEntities()).thenReturn(List.of(villager));f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","remove",registrar.id().toString()});directory.remove(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(directory.find(registrar.id()).isEmpty());verify(villager).remove();
}
}
private static void click(games.dmg.fantasystick.party.PartyRegistrarMenus menus,org.bukkit.entity.Player player,org.bukkit.inventory.Inventory inventory,int slot,boolean right){var event=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(event.getInventory()).thenReturn(inventory);when(event.getWhoClicked()).thenReturn(player);when(event.getCurrentItem()).thenReturn(inventory.getItem(slot));when(event.isRightClick()).thenReturn(right);menus.click(event);}
}
@@ -200,7 +200,7 @@ class QuickstepNativeTest {
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("distance")));
assertEquals(0, new java.math.BigDecimal("1.5").compareTo(definition.parameters().get("speed")));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("config", "experience", "give", "inspect", "recover"),
assertEquals(List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"co"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unsupported"}).isEmpty());
@@ -128,7 +128,7 @@ class SecondWindNativeTest {
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.TEN.pow(500)));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("config", "experience", "give", "inspect", "recover"),
assertEquals(List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second"}).isEmpty());
@@ -89,13 +89,19 @@ 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(), BigInteger.ZERO,
new games.dmg.fantasystick.persistence.CharacterGear(games.dmg.fantasystick.persistence.GearTier.DIAMOND,
games.dmg.fantasystick.persistence.GearTier.GOLD, games.dmg.fantasystick.persistence.GearTier.LEATHER));
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 complete = String.join("", pages).replace("\n", "");
for (String expected : List.of("Current gear", "Head: Cosmetic only", "Chestplate: Diamond", "Leggings: Gold", "Boots: Leather"))
assertTrue(complete.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()));
assertFalse(wind.contains("unknown:雪"), "Each learned ability starts its own page/section");
@@ -32,6 +32,7 @@ class DistributionTest {
assertEquals(java.util.Set.of("fantasystick"), metadata.getCommands().keySet());
assertEquals("fantasystick.admin", metadata.getCommands().get("fantasystick").get("permission"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("experience give"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("shop <place [name...]|remove <shop-uuid>|list|restock>"));
assertEquals(1, metadata.getPermissions().size());
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
@@ -84,6 +85,20 @@ 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);
}
for (String setting : java.util.List.of("gear-price-variance-percent: 10",
"gear-stock-leather-min: 25", "gear-stock-leather-max: 50",
"gear-stock-copper-min: 20", "gear-stock-copper-max: 40",
"gear-stock-gold-min: 15", "gear-stock-gold-max: 30",
"gear-stock-iron-min: 10", "gear-stock-iron-max: 20",
"gear-stock-diamond-min: 5", "gear-stock-diamond-max: 10",
"gear-stock-netherite-min: 1", "gear-stock-netherite-max: 3")) {
assertTrue(defaults.contains(setting), "Missing packaged armor-market 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",
@@ -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());
@@ -0,0 +1,127 @@
package games.dmg.fantasystick.party;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.*;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CharacterPartyDirectoryTest {
@TempDir Path data;
@Test
void persistsUniquePartiesIndefiniteInvitationsAndAtomicAcceptanceAcrossRestart() throws Exception {
var repository = new YamlPartyRepository(data);
var leader = UUID.randomUUID(); var member = UUID.randomUUID(); var otherLeader = UUID.randomUUID();
UUID firstId;
try (var parties = new CharacterPartyDirectory(repository, () -> 2)) {
var first = parties.create(leader, " Cafe\u0301 Heroes ").join();
assertEquals(CharacterPartyDirectory.Status.APPLIED, first.status());
firstId = first.party().orElseThrow().id();
assertEquals(CharacterPartyDirectory.Status.NAME_TAKEN,
parties.create(otherLeader, "CAFÉ HEROES").join().status());
assertEquals(CharacterPartyDirectory.Status.APPLIED, parties.invite(leader, firstId, member).join().status());
}
try (var parties = new CharacterPartyDirectory(repository, () -> 2)) {
var invitation = parties.invitationsFor(member).getFirst();
assertEquals(CharacterPartyDirectory.Status.APPLIED,
parties.accept(member, invitation.id()).join().status());
assertEquals(firstId, parties.partyOf(member).orElseThrow().id());
assertTrue(parties.invitationsFor(member).isEmpty());
assertEquals(CharacterPartyDirectory.Status.INVALID,
parties.accept(UUID.randomUUID(), invitation.id()).join().status());
}
}
@Test
void joiningInvalidatesOtherInvitationsAndLeadershipAndDefeatHaveDurableSemantics() throws Exception {
var repository = new YamlPartyRepository(data);
var a = UUID.randomUUID(); var b = UUID.randomUUID(); var target = UUID.randomUUID();
try (var parties = new CharacterPartyDirectory(repository, () -> 6)) {
var one = parties.create(a, "One").join().party().orElseThrow();
var two = parties.create(b, "Two").join().party().orElseThrow();
parties.invite(a, one.id(), target).join();
parties.invite(b, two.id(), target).join();
var accepted = parties.invitationsFor(target).stream().filter(i -> i.party().equals(one.id())).findFirst().orElseThrow();
assertEquals(CharacterPartyDirectory.Status.APPLIED, parties.accept(target, accepted.id()).join().status());
assertTrue(parties.invitationsFor(target).isEmpty());
assertEquals(CharacterPartyDirectory.Status.APPLIED, parties.transfer(a, one.id(), target).join().status());
assertEquals(target, parties.partyOf(a).orElseThrow().leader());
assertEquals(CharacterPartyDirectory.Defeat.MEMBER_REMOVED, parties.defeat(a).join());
assertTrue(parties.partyOf(a).isEmpty());
assertEquals(CharacterPartyDirectory.Defeat.PARTY_DISBANDED, parties.defeat(target).join());
assertTrue(parties.partyById(one.id()).isEmpty());
}
try (var parties = new CharacterPartyDirectory(repository, () -> 6)) {
assertTrue(parties.partyByCanonicalName("one").isEmpty());
assertEquals(CharacterPartyDirectory.Status.APPLIED, parties.create(UUID.randomUUID(), "ONE").join().status());
}
}
@Test
void leaderAndInviteeMutationsRequireTheirAuthorityAndDisbandCleansEveryReference() throws Exception {
var repository = new YamlPartyRepository(data);
var leader = UUID.randomUUID(); var member = UUID.randomUUID(); var target = UUID.randomUUID();
try (var parties = new CharacterPartyDirectory(repository, () -> 6)) {
var party = parties.create(leader, "Lifecycle").join().party().orElseThrow();
var declined = parties.invite(leader, party.id(), target).join().invitation().orElseThrow();
assertEquals(CharacterPartyDirectory.Status.INVALID,
parties.decline(member, declined.id()).join().status());
assertEquals(CharacterPartyDirectory.Status.APPLIED,
parties.decline(target, declined.id()).join().status());
var cancelled = parties.invite(leader, party.id(), target).join().invitation().orElseThrow();
assertEquals(CharacterPartyDirectory.Status.APPLIED,
parties.cancel(leader, party.id(), cancelled.id()).join().status());
var defeatedInvitation=parties.invite(leader,party.id(),target).join().invitation().orElseThrow();
assertEquals(CharacterPartyDirectory.Defeat.UNCHANGED,parties.defeat(target).join());
assertTrue(parties.invitationsFor(target).isEmpty());
assertEquals(CharacterPartyDirectory.Status.INVALID,parties.accept(target,defeatedInvitation.id()).join().status());
var accepted = parties.invite(leader, party.id(), member).join().invitation().orElseThrow();
parties.accept(member, accepted.id()).join();
assertEquals(CharacterPartyDirectory.Status.INVALID, parties.kick(member, party.id(), leader).join().status());
assertEquals(CharacterPartyDirectory.Status.APPLIED, parties.kick(leader, party.id(), member).join().status());
assertTrue(parties.partyOf(member).isEmpty());
var rejoin = parties.invite(leader, party.id(), member).join().invitation().orElseThrow();
parties.accept(member, rejoin.id()).join();
assertEquals(CharacterPartyDirectory.Status.APPLIED, parties.leave(member, party.id()).join().status());
assertEquals(CharacterPartyDirectory.Status.APPLIED, parties.leave(leader, party.id()).join().status());
assertTrue(parties.partyById(party.id()).isEmpty());
}
}
@Test
void serialPaidTransferRejectsARepeatedStaleActionBeforeASecondDebit()throws Exception{
var characterRepository=new YamlCharacterRepository(data);var created=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());characterRepository.create(created);var funded=new CharacterRecord(created.id(),created.name(),created.revision().add(BigInteger.ONE),created.stats(),created.level(),created.experience(),created.unspentPoints(),created.health(),created.learned(),created.loadout(),created.passives(),created.cooldowns(),created.restrictedKills(),false,null,null,BigInteger.valueOf(20),true,created.rewardReceipts());characterRepository.replace(funded,created.revision());
try(var characters=new CharacterStorage(characterRepository,Clock.systemUTC());var parties=new CharacterPartyDirectory(new YamlPartyRepository(data),()->6)){
UUID target=UUID.randomUUID();var party=parties.create(funded.id(),"Paid").join().party().orElseThrow();var invitation=parties.invite(funded.id(),party.id(),target).join().invitation().orElseThrow();parties.accept(target,invitation.id()).join();var first=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.TRANSFER,funded.id(),funded.revision(),BigInteger.TEN,null,party.id(),target,parties.partyById(party.id()).orElseThrow().revision());var repeated=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.TRANSFER,funded.id(),funded.revision(),BigInteger.TEN,null,party.id(),target,parties.partyById(party.id()).orElseThrow().revision());var firstResult=parties.paidTransfer(first,characters);var repeatedResult=parties.paidTransfer(repeated,characters);assertEquals(CharacterPartyDirectory.Status.APPLIED,firstResult.join().result().status());var refused=repeatedResult.join();assertEquals(CharacterPartyDirectory.Status.INVALID,refused.result().status());assertNull(refused.spend());assertEquals(BigInteger.TEN,characters.find(funded.id()).join().orElseThrow().coins());
}
}
@Test
void failedPartyPersistenceCompensatesTheDebitExactlyOnce()throws Exception{
var characterRepository=new YamlCharacterRepository(data);var created=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());characterRepository.create(created);var funded=new CharacterRecord(created.id(),created.name(),created.revision().add(BigInteger.ONE),created.stats(),created.level(),created.experience(),created.unspentPoints(),created.health(),created.learned(),created.loadout(),created.passives(),created.cooldowns(),created.restrictedKills(),false,null,null,BigInteger.valueOf(20),true,created.rewardReceipts());characterRepository.replace(funded,created.revision());
try(var characters=new CharacterStorage(characterRepository,Clock.systemUTC());var parties=new CharacterPartyDirectory(new YamlPartyRepository(data),()->6)){
UUID target=UUID.randomUUID();var party=parties.create(funded.id(),"Failure").join().party().orElseThrow();var invitation=parties.invite(funded.id(),party.id(),target).join().invitation().orElseThrow();parties.accept(target,invitation.id()).join();Path file=data.resolve("parties.yml");Files.delete(file);Files.createDirectory(file);Files.writeString(file.resolve("block"),"block");var plan=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.TRANSFER,funded.id(),funded.revision(),BigInteger.TEN,null,party.id(),target,parties.partyById(party.id()).orElseThrow().revision());var outcome=parties.paidTransfer(plan,characters).join();assertEquals(CharacterPartyDirectory.Status.INVALID,outcome.result().status());assertEquals(CharacterStorage.CoinSpendStatus.STALE,outcome.spend().status());var restored=characters.find(funded.id()).join().orElseThrow();assertEquals(BigInteger.valueOf(20),restored.coins());assertTrue(restored.rewardReceipts().containsAll(java.util.Set.of(plan.id(),PartyOperationStore.refundId(plan.id()))));assertEquals(funded.id(),parties.partyById(party.id()).orElseThrow().leader());
}
}
@Test
void invitationsDoNotReserveCapacityAndExistingOverCapacityRosterIsGrandfathered() throws Exception {
var repository = new YamlPartyRepository(data);
var leader = UUID.randomUUID(); var first = UUID.randomUUID(); var second = UUID.randomUUID();
try (var parties = new CharacterPartyDirectory(repository, () -> 2)) {
var party = parties.create(leader, "Capacity").join().party().orElseThrow();
var firstInvite = parties.invite(leader, party.id(), first).join().invitation().orElseThrow();
var secondInvite = parties.invite(leader, party.id(), second).join().invitation().orElseThrow();
assertEquals(CharacterPartyDirectory.Status.APPLIED, parties.accept(first, firstInvite.id()).join().status());
assertEquals(CharacterPartyDirectory.Status.FULL, parties.accept(second, secondInvite.id()).join().status());
assertEquals(CharacterPartyDirectory.Status.DUPLICATE,
parties.invite(leader, party.id(), second).join().status());
}
}
}
@@ -0,0 +1,30 @@
package games.dmg.fantasystick.party;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class PartyNamesTest {
@Test
void trimsNormalizesAndCaseFoldsCanonicalNamesWhilePreservingDisplayCase() {
var name = PartyNames.parse(" Cafe\u0301 Heroes ").orElseThrow();
assertEquals("Café Heroes", name.display());
assertEquals("café heroes", name.canonical());
assertEquals(11, name.display().codePointCount(0, name.display().length()));
}
@Test
void acceptsBoundaryLengthsAndInternalSpaces() {
assertTrue(PartyNames.parse("A").isPresent());
assertTrue(PartyNames.parse("A".repeat(32)).isPresent());
assertEquals("Two Spaces", PartyNames.parse("Two Spaces").orElseThrow().display());
}
@Test
void rejectsBlankOverlongControlAndFormattingNames() {
assertTrue(PartyNames.parse(" \t ").isEmpty());
assertTrue(PartyNames.parse("A".repeat(33)).isEmpty());
assertTrue(PartyNames.parse("Line\nBreak").isEmpty());
assertTrue(PartyNames.parse("Hidden\u200EFormat").isEmpty());
}
}
@@ -0,0 +1,29 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.shop.ArmorProduct;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ArmorPurchaseOperationStoreTest {
@TempDir Path data;
@Test
void durablePlansRoundTripAndCompleteIdempotently() {
var plan = new ArmorPurchaseOperationStore.Plan(UUID.randomUUID(), UUID.randomUUID(), BigInteger.TEN,
UUID.randomUUID(), new ArmorProduct(GearSlot.BOOTS, GearTier.DIAMOND), BigInteger.valueOf(151));
try (var store = new ArmorPurchaseOperationStore(data)) {
assertEquals(plan, store.prepare(plan).toCompletableFuture().join());
}
try (var restarted = new ArmorPurchaseOperationStore(data)) {
assertEquals(java.util.List.of(plan), restarted.load().toCompletableFuture().join());
restarted.complete(plan.id()).toCompletableFuture().join();
restarted.complete(plan.id()).toCompletableFuture().join();
assertTrue(restarted.load().toCompletableFuture().join().isEmpty());
}
}
}
@@ -0,0 +1,37 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.crafting.CharacterFactory;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.Clock;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CharacterGearTest {
@TempDir Path data;
@Test void strictAtomicPurchasesPersistGearAndCoinsAcrossRestart()throws Exception{
var repository=new YamlCharacterRepository(data);var created=new CharacterFactory().create(ProgressionRules.defaults());repository.create(created);var funded=funded(created,BigInteger.valueOf(1000));repository.replace(funded,created.revision());UUID operation=UUID.randomUUID();
try(var storage=new CharacterStorage(repository,Clock.systemUTC())){
var bought=storage.purchaseGear(funded.id(),funded.revision(),operation,GearSlot.CHESTPLATE,GearTier.IRON,BigInteger.valueOf(75)).join();assertEquals(CharacterStorage.GearPurchaseStatus.APPLIED,bought.status());assertEquals(BigInteger.valueOf(925),bought.record().orElseThrow().coins());assertEquals(GearTier.IRON,bought.record().orElseThrow().gear().tier(GearSlot.CHESTPLATE));
assertEquals(CharacterStorage.GearPurchaseStatus.DUPLICATE,storage.purchaseGear(funded.id(),funded.revision(),operation,GearSlot.CHESTPLATE,GearTier.IRON,BigInteger.valueOf(75)).join().status());
var current=bought.record().orElseThrow();assertEquals(CharacterStorage.GearPurchaseStatus.NOT_UPGRADE,storage.purchaseGear(current.id(),current.revision(),UUID.randomUUID(),GearSlot.CHESTPLATE,GearTier.GOLD,BigInteger.valueOf(25)).join().status());assertEquals(CharacterStorage.GearPurchaseStatus.INSUFFICIENT,storage.purchaseGear(current.id(),current.revision(),UUID.randomUUID(),GearSlot.BOOTS,GearTier.NETHERITE,BigInteger.valueOf(1000)).join().status());
}
var reloaded=repository.load(funded.id()).orElseThrow();assertEquals(GearTier.IRON,reloaded.gear().chestplate());assertEquals(GearTier.NONE,reloaded.gear().leggings());assertEquals(GearTier.NONE,reloaded.gear().boots());assertEquals(BigInteger.valueOf(925),reloaded.coins());try(var storage=new CharacterStorage(repository,Clock.systemUTC())){var dead=storage.recordDeath(reloaded.id(),true).join().orElseThrow();assertEquals(GearTier.IRON,dead.gear().chestplate());assertEquals(BigInteger.ZERO,dead.coins());var revived=storage.restoreDeathRecord(dead.id(),dead.deathId()).join().orElseThrow();assertEquals(GearTier.IRON,revived.gear().chestplate());}
}
private static CharacterRecord funded(CharacterRecord old,BigInteger coins){return 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(),false,null,null,coins,true,old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());}
@Test void concurrentRequestsCannotDoubleSpendOrInstallTwoUpgrades()throws Exception{var repository=new YamlCharacterRepository(data);var created=new CharacterFactory().create(ProgressionRules.defaults());repository.create(created);var funded=funded(created,BigInteger.valueOf(100));repository.replace(funded,created.revision());try(var storage=new CharacterStorage(repository,Clock.systemUTC())){var first=storage.purchaseGear(funded.id(),funded.revision(),UUID.randomUUID(),GearSlot.BOOTS,GearTier.LEATHER,BigInteger.TWO);var second=storage.purchaseGear(funded.id(),funded.revision(),UUID.randomUUID(),GearSlot.BOOTS,GearTier.IRON,BigInteger.valueOf(75));assertEquals(CharacterStorage.GearPurchaseStatus.APPLIED,first.join().status());assertEquals(CharacterStorage.GearPurchaseStatus.STALE,second.join().status());var saved=storage.find(funded.id()).join().orElseThrow();assertEquals(BigInteger.valueOf(98),saved.coins());assertEquals(GearTier.LEATHER,saved.gear().boots());}}
@Test void failedCharacterWriteChargesAndChangesNothing()throws Exception{var repository=new YamlCharacterRepository(data);var created=new CharacterFactory().create(ProgressionRules.defaults());repository.create(created);var funded=funded(created,BigInteger.valueOf(100));repository.replace(funded,created.revision());var failing=new YamlCharacterRepository(data,(source,target)->{throw new java.io.IOException("forced");});try(var storage=new CharacterStorage(failing,Clock.systemUTC())){assertThrows(java.util.concurrent.CompletionException.class,()->storage.purchaseGear(funded.id(),funded.revision(),UUID.randomUUID(),GearSlot.BOOTS,GearTier.LEATHER,BigInteger.TWO).join());}var unchanged=repository.load(funded.id()).orElseThrow();assertEquals(BigInteger.valueOf(100),unchanged.coins());assertEquals(GearTier.NONE,unchanged.gear().boots());}
@Test void validatesHelmetFreeSlotsAndTierOrder(){
assertEquals(java.util.List.of(GearSlot.CHESTPLATE,GearSlot.LEGGINGS,GearSlot.BOOTS),java.util.List.of(GearSlot.values()));
assertTrue(GearTier.NETHERITE.higherThan(GearTier.DIAMOND));assertFalse(GearTier.COPPER.higherThan(GearTier.GOLD));assertThrows(IllegalArgumentException.class,()->new CharacterGear(null,GearTier.NONE,GearTier.NONE));
}
}
@@ -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,82 @@ 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 stableCoinDebitIsAtomicIdempotentAndNeverOverdraws() 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(10), true, java.util.Set.of());
var operation = UUID.randomUUID();
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(live).join();
var charged = storage.spendCoins(live.id(), live.revision(), operation, BigInteger.TEN).join();
assertEquals(CharacterStorage.CoinSpendStatus.APPLIED, charged.status());
assertEquals(BigInteger.ZERO, charged.record().orElseThrow().coins());
var duplicate = storage.spendCoins(live.id(), live.revision(), operation, BigInteger.TEN).join();
assertEquals(CharacterStorage.CoinSpendStatus.DUPLICATE, duplicate.status());
assertEquals(BigInteger.ZERO, duplicate.record().orElseThrow().coins());
var insufficient = storage.spendCoins(live.id(), duplicate.record().orElseThrow().revision(),
UUID.randomUUID(), BigInteger.ONE).join();
assertEquals(CharacterStorage.CoinSpendStatus.INSUFFICIENT, insufficient.status());
}
}
@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"));
}
}
}
@@ -0,0 +1,51 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class GearSettingsTest {
@TempDir Path data;
@Test void publishesAllSixDocumentedBasePrices(){var expected=Map.of("gear-price-leather","2","gear-price-copper","10","gear-price-gold","25","gear-price-iron","75","gear-price-diamond","150","gear-price-netherite","500");assertEquals(expected,expected.keySet().stream().collect(java.util.stream.Collectors.toMap(key->key,GearSettings::defaultValue)));}
@Test void publishesDailyMarketDefaultsAndValidatesRanges(){
var defaults=GearSettings.keys().stream().collect(java.util.stream.Collectors.toMap(key->key,GearSettings::defaultValue));
assertEquals("10",defaults.get("gear-price-variance-percent"));
assertEquals(Map.ofEntries(
Map.entry("gear-stock-leather-min","25"),Map.entry("gear-stock-leather-max","50"),
Map.entry("gear-stock-copper-min","20"),Map.entry("gear-stock-copper-max","40"),
Map.entry("gear-stock-gold-min","15"),Map.entry("gear-stock-gold-max","30"),
Map.entry("gear-stock-iron-min","10"),Map.entry("gear-stock-iron-max","20"),
Map.entry("gear-stock-diamond-min","5"),Map.entry("gear-stock-diamond-max","10"),
Map.entry("gear-stock-netherite-min","1"),Map.entry("gear-stock-netherite-max","3")),
defaults.entrySet().stream().filter(entry->entry.getKey().startsWith("gear-stock-"))
.collect(java.util.stream.Collectors.toMap(Map.Entry::getKey,Map.Entry::getValue)));
try(var settings=new GearSettings(data,defaults)){
var rules=settings.rules();
assertEquals(10,rules.variancePercent());
assertEquals(25,rules.stock().get(GearTier.LEATHER).minimum());
assertEquals(3,rules.stock().get(GearTier.NETHERITE).maximum());
assertThrows(IllegalArgumentException.class,()->settings.set("gear-price-variance-percent","101"));
assertThrows(IllegalArgumentException.class,()->settings.set("gear-stock-leather-min","51"));
assertThrows(IllegalArgumentException.class,()->settings.set("gear-stock-leather-min","0"));
}
}
@Test void failedSavePreservesEffectiveValuesAndUnrelatedState()throws Exception{
var blocked=data.resolve("blocked");Files.writeString(blocked,"not a directory");
var defaults=GearSettings.keys().stream().collect(java.util.stream.Collectors.toMap(key->key,GearSettings::defaultValue));
try(var settings=new GearSettings(blocked,defaults)){
assertThrows(java.util.concurrent.CompletionException.class,()->settings.set("gear-price-variance-percent","20").toCompletableFuture().join());
assertEquals("10",settings.value("gear-price-variance-percent"));
assertEquals("not a directory",Files.readString(blocked));
}
}
@Test void validatesPersistsPublishesAndPreservesUnrelatedKeys()throws Exception{
Files.writeString(data.resolve("config.yml"),"unrelated: keep\ngear-price-leather: 2\n");
try(var settings=new GearSettings(data,Map.of("gear-price-leather","2","gear-price-copper","10","gear-price-gold","25","gear-price-iron","75","gear-price-diamond","150","gear-price-netherite","500"))){assertEquals(500,settings.price(GearTier.NETHERITE));var update=settings.set("gear-price-netherite","2147483647").toCompletableFuture().join();assertEquals(500,settings.price(GearTier.NETHERITE));assertTrue(settings.publish(update));assertEquals(Integer.MAX_VALUE,settings.price(GearTier.NETHERITE));assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));assertThrows(IllegalArgumentException.class,()->settings.set("gear-price-gold","-1"));assertThrows(IllegalArgumentException.class,()->settings.set("gear-price-gold","1.5"));}
}
}
@@ -0,0 +1,29 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class PartyEconomySettingsTest {
@TempDir Path data;
@Test void validatesPublishesAndPreservesUnrelatedConfiguration() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: keep\nparty-registration-price: 10\n");
try (var settings = new PartyEconomySettings(data, Map.of(
PartyEconomySettings.REGISTRATION, "10", PartyEconomySettings.LEADER_CHANGE, "10"))) {
var update = settings.set(PartyEconomySettings.REGISTRATION, "0").toCompletableFuture().join();
assertEquals(10, settings.registrationPrice());
assertTrue(settings.publish(update));
assertEquals(0, settings.registrationPrice());
assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));
assertThrows(IllegalArgumentException.class,
() -> settings.set(PartyEconomySettings.LEADER_CHANGE, "2147483648"));
assertThrows(IllegalArgumentException.class,
() -> settings.set(PartyEconomySettings.LEADER_CHANGE, "-1"));
}
}
}
@@ -0,0 +1,37 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.CompletionException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class PartyOperationStoreTest {
@TempDir Path data;
@Test void durablePlansReserveCanonicalNamesAndCompleteIdempotently() {
UUID actor=UUID.randomUUID();
var first=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.CREATE,actor,
BigInteger.ZERO,BigInteger.TEN,"Café Heroes",null,null,null);
try(var store=new PartyOperationStore(data)){assertEquals(first,store.prepare(first).toCompletableFuture().join());}
try(var restarted=new PartyOperationStore(data)){
assertEquals(java.util.List.of(first),restarted.load().toCompletableFuture().join());
var duplicate=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.CREATE,UUID.randomUUID(),
BigInteger.ZERO,BigInteger.TEN," CAFE\u0301 HEROES ",null,null,null);
assertThrows(CompletionException.class,()->restarted.prepare(duplicate).toCompletableFuture().join());
restarted.complete(first.id()).toCompletableFuture().join();
restarted.complete(first.id()).toCompletableFuture().join();
assertTrue(restarted.load().toCompletableFuture().join().isEmpty());
}
}
@Test void rejectsInvalidOperationShapes() {
assertThrows(IllegalArgumentException.class,()->new PartyOperationStore.Plan(UUID.randomUUID(),
PartyOperationStore.Kind.TRANSFER,UUID.randomUUID(),BigInteger.ZERO,BigInteger.TEN,"name",null,null,null));
assertThrows(IllegalArgumentException.class,()->new PartyOperationStore.Plan(UUID.randomUUID(),
PartyOperationStore.Kind.CREATE,UUID.randomUUID(),BigInteger.ZERO,BigInteger.TEN,"bad\nname",null,null,null));
}
}
@@ -0,0 +1,39 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.shop.*;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class YamlArmorCatalogRepositoryTest {
@TempDir Path data;
@Test
void durablyRoundTripsOneCompleteCatalogWithUtcMetadataAndEveryProduct() throws Exception {
var products = new LinkedHashMap<ArmorProduct, ArmorCatalog.Offer>();
int value = 1;
for (var slot : GearSlot.values()) for (var tier : GearTier.values()) {
if (tier != GearTier.NONE) products.put(new ArmorProduct(slot, tier),
new ArmorCatalog.Offer(BigInteger.valueOf(value), value++));
}
var catalog = new ArmorCatalog(LocalDate.of(2026, 9, 21), UUID.randomUUID(),
Instant.parse("2026-09-21T12:34:56Z"), ArmorCatalog.RefreshReason.ADMINISTRATIVE, products);
var repository = new YamlArmorCatalogRepository(data);
repository.save(catalog);
assertEquals(catalog, new YamlArmorCatalogRepository(data).load().orElseThrow());
var yaml = Files.readString(data.resolve("armor-catalog.yml"));
assertTrue(yaml.contains("refreshed-at: '2026-09-21T12:34:56Z'"));
assertTrue(yaml.contains("CHESTPLATE-LEATHER"));
assertTrue(yaml.contains("BOOTS-NETHERITE"));
}
}
@@ -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,4 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;import games.dmg.fantasystick.shop.FantasyArmorer;import java.nio.file.Path;import java.util.*;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class YamlFantasyArmorerRepositoryTest{@TempDir Path data;@Test void preservesExactDurableNamedLocationsAndRejectsDuplicates()throws Exception{var repository=new YamlFantasyArmorerRepository(data);var value=new FantasyArmorer(UUID.randomUUID(),"Forge",UUID.randomUUID(),1.25,64,-2.5,90,10);repository.save(List.of(value));assertEquals(List.of(value),repository.load());assertThrows(IllegalArgumentException.class,()->repository.save(List.of(value,value)));}
@Test void activePurchaseLeasePreventsRemovalUntilItFinishes()throws Exception{try(var directory=new games.dmg.fantasystick.shop.FantasyArmorerDirectory(new YamlFantasyArmorerRepository(data))){var shop=directory.place("Forge",UUID.randomUUID(),1,2,3,4,5).join();var lease=directory.acquire(shop.id()).orElseThrow();assertThrows(java.util.concurrent.CompletionException.class,()->directory.remove(shop.id()).join());assertEquals(shop,directory.find(shop.id()).orElseThrow());lease.close();assertEquals(shop,directory.remove(shop.id()).join().orElseThrow());assertTrue(directory.find(shop.id()).isEmpty());}}}
@@ -0,0 +1,32 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.party.PartyRegistrar;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class YamlPartyRegistrarRepositoryTest {
@TempDir Path data;
@Test void persistsStableRegistrarIdentityNameAndExactLocation() throws Exception {
var registrar = new PartyRegistrar(UUID.randomUUID(), "Central Registrar", UUID.randomUUID(),
1.25, -64.5, 99.75, 123.5f, -12.25f);
var repository = new YamlPartyRegistrarRepository(data);
repository.save(List.of(registrar));
assertEquals(List.of(registrar), repository.load());
}
@Test void refusesDuplicateIdentityAndMalformedNames() {
var id = UUID.randomUUID(); var world = UUID.randomUUID();
assertThrows(IllegalArgumentException.class,
() -> new PartyRegistrar(id, "Bad\nName", world, 0, 0, 0, 0, 0));
var repository = new YamlPartyRegistrarRepository(data);
var first = new PartyRegistrar(id, "One", world, 0, 0, 0, 0, 0);
var second = new PartyRegistrar(id, "Two", world, 1, 0, 0, 0, 0);
assertThrows(IllegalArgumentException.class, () -> repository.save(List.of(first, second)));
}
}
@@ -0,0 +1,54 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.party.CharacterParty;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class YamlPartyRepositoryTest {
@TempDir Path data;
@Test
void roundTripsStableNamesLeadershipMembersAndIndefiniteInvitations() throws Exception {
var leader = UUID.randomUUID();
var member = UUID.randomUUID();
var invited = UUID.randomUUID();
var invitation = new CharacterParty.Invitation(UUID.randomUUID(), invited);
var party = new CharacterParty(UUID.randomUUID(), BigInteger.valueOf(7), "Café Heroes", "café heroes",
leader, Set.of(leader, member), List.of(invitation));
var repository = new YamlPartyRepository(data);
repository.save(List.of(party));
assertEquals(List.of(party), repository.load());
assertTrue(Files.readString(data.resolve("parties.yml")).contains("café heroes"));
assertFalse(Files.readString(data.resolve("parties.yml")).contains("expires"));
}
@Test
void rejectsDuplicateCanonicalNamesMembershipAndInvitationTargets() throws Exception {
var character = UUID.randomUUID();
var first = party("Alpha", "alpha", character);
var second = party("ALPHA", "alpha", character);
var repository = new YamlPartyRepository(data);
assertThrows(IllegalArgumentException.class, () -> repository.save(List.of(first, second)));
var target = UUID.randomUUID();
var leader = UUID.randomUUID();
assertThrows(IllegalArgumentException.class, () -> new CharacterParty(UUID.randomUUID(), BigInteger.ZERO,
"Beta", "beta", leader, Set.of(leader), List.of(new CharacterParty.Invitation(UUID.randomUUID(), target),
new CharacterParty.Invitation(UUID.randomUUID(), target))));
}
private static CharacterParty party(String display, String canonical, UUID character) {
return new CharacterParty(UUID.randomUUID(), BigInteger.ZERO, display, canonical, character,
Set.of(character), List.of());
}
}
@@ -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)));
}
}
@@ -0,0 +1,42 @@
package games.dmg.fantasystick.shop;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.GearSlot;
import games.dmg.fantasystick.persistence.GearTier;
import java.math.BigInteger;
import java.time.Instant;
import java.time.LocalDate;
import java.util.EnumMap;
import org.junit.jupiter.api.Test;
class ArmorCatalogGeneratorTest {
@Test
void independentlyGeneratesAllProductsWithExactPriceAndStockEndpoints() {
var bases = new EnumMap<GearTier, BigInteger>(GearTier.class);
var stock = new EnumMap<GearTier, ArmorMarketRules.StockRange>(GearTier.class);
for (var tier : GearTier.values()) {
if (tier == GearTier.NONE) continue;
bases.put(tier, tier == GearTier.LEATHER ? BigInteger.ZERO : BigInteger.valueOf(101));
stock.put(tier, new ArmorMarketRules.StockRange(2, 4));
}
var rules = new ArmorMarketRules(bases, 10, stock);
var day = LocalDate.of(2026, 9, 21);
var refreshedAt = Instant.parse("2026-09-21T00:00:00Z");
var minimum = ArmorCatalogGenerator.generate(day, refreshedAt, ArmorCatalog.RefreshReason.SCHEDULED,
rules, (lower, upper) -> lower);
var maximum = ArmorCatalogGenerator.generate(day, refreshedAt, ArmorCatalog.RefreshReason.ADMINISTRATIVE,
rules, (lower, upper) -> upper);
assertEquals(18, minimum.products().size());
assertEquals(BigInteger.ONE,
minimum.offer(new ArmorProduct(GearSlot.CHESTPLATE, GearTier.LEATHER)).price());
assertEquals(BigInteger.valueOf(91),
minimum.offer(new ArmorProduct(GearSlot.LEGGINGS, GearTier.COPPER)).price());
assertEquals(BigInteger.valueOf(112),
maximum.offer(new ArmorProduct(GearSlot.BOOTS, GearTier.NETHERITE)).price());
assertTrue(minimum.products().values().stream().allMatch(offer -> offer.remaining() == 2));
assertTrue(maximum.products().values().stream().allMatch(offer -> offer.remaining() == 4));
assertNotEquals(minimum.revision(), maximum.revision());
}
}
@@ -0,0 +1,299 @@
package games.dmg.fantasystick.shop;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.*;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ArmorMarketTest {
@TempDir Path data;
private static final class MutableClock extends Clock {
private Instant instant;
private MutableClock(Instant instant){this.instant=instant;}
void set(Instant value){instant=value;}
@Override public java.time.ZoneId getZone(){return ZoneOffset.UTC;}
@Override public Clock withZone(java.time.ZoneId zone){return this;}
@Override public Instant instant(){return instant;}
}
@Test
void failedAdministrativeRestockLeavesThePriorCatalogPublishedAndDurable() throws Exception {
var settings=new GearSettings(data,Map.of());var clock=Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC);
var durable=new YamlArmorCatalogRepository(data);
class FailingCatalogs implements ArmorCatalogRepository{
boolean fail;
@Override public java.util.Optional<ArmorCatalog> load()throws java.io.IOException{return durable.load();}
@Override public void save(ArmorCatalog catalog)throws java.io.IOException{if(fail)throw new java.io.IOException("injected restock failure");durable.save(catalog);}
}
var catalogs=new FailingCatalogs();
try(var characters=new CharacterStorage(new YamlCharacterRepository(data),clock);var market=new ArmorMarket(catalogs,
new ArmorPurchaseOperationStore(data),characters,settings::rules,clock,(minimum,maximum)->minimum)){
var prior=market.current().toCompletableFuture().join();catalogs.fail=true;
assertThrows(java.util.concurrent.CompletionException.class,()->market.restock().toCompletableFuture().join());
assertEquals(prior,market.snapshot().orElseThrow());
assertEquals(prior,durable.load().orElseThrow());
}finally{settings.close();}
}
@Test
void acknowledgedConfigurationChangesWaitForTheNextRestock() throws Exception {
var settings=new GearSettings(data,Map.of());
var clock=Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC);
try(var characters=new CharacterStorage(new YamlCharacterRepository(data),clock);
var market=new ArmorMarket(new YamlArmorCatalogRepository(data),new ArmorPurchaseOperationStore(data),
characters,settings::rules,clock,(minimum,maximum)->minimum)){
var product=new ArmorProduct(GearSlot.CHESTPLATE,GearTier.LEATHER);
var current=market.current().toCompletableFuture().join();
var variance=settings.set("gear-price-variance-percent","0").toCompletableFuture().join();
assertTrue(settings.publish(variance));
var base=settings.set("gear-price-leather","100").toCompletableFuture().join();
assertTrue(settings.publish(base));
assertEquals(current.offer(product).price(),market.current().toCompletableFuture().join().offer(product).price());
assertEquals(java.math.BigInteger.valueOf(100),market.restock().toCompletableFuture().join().offer(product).price());
}finally{settings.close();}
}
@Test
void administrativeRestockRerollsImmediatelyWithoutShiftingMidnightUtcRollover() throws Exception {
var settings=new GearSettings(data,Map.of());
var clock=new MutableClock(Instant.parse("2026-09-21T23:59:30Z"));
try(var characters=new CharacterStorage(new YamlCharacterRepository(data),clock);
var market=new ArmorMarket(new YamlArmorCatalogRepository(data),new ArmorPurchaseOperationStore(data),
characters,settings::rules,clock,(minimum,maximum)->minimum)){
var initial=market.current().toCompletableFuture().join();
var manual=market.restock().toCompletableFuture().join();
assertEquals(ArmorCatalog.RefreshReason.ADMINISTRATIVE,manual.reason());
assertNotEquals(initial.revision(),manual.revision());
assertEquals(Instant.parse("2026-09-22T00:00:00Z"),ArmorMarket.nextScheduledRestock(clock.instant()));
clock.set(Instant.parse("2026-09-22T00:00:00Z"));
var scheduled=market.current().toCompletableFuture().join();
assertEquals(java.time.LocalDate.of(2026,9,22),scheduled.day());
assertEquals(ArmorCatalog.RefreshReason.SCHEDULED,scheduled.reason());
assertNotEquals(manual.revision(),scheduled.revision());
}finally{settings.close();}
}
@Test
void restartCompletesAReservedPurchaseAfterCatalogSettlementFailureWithoutDoubleChargeOrStockLoss() throws Exception {
var settings=new GearSettings(data,Map.of());
var clock=Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC);
var characterRepository=new YamlCharacterRepository(data);
var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var character=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,java.math.BigInteger.valueOf(100),true,java.util.Set.of(),
java.math.BigInteger.ZERO,CharacterGear.none());
var durable=new YamlArmorCatalogRepository(data);
class FailingCatalogs implements ArmorCatalogRepository {
int saves;
@Override public java.util.Optional<ArmorCatalog> load()throws java.io.IOException{return durable.load();}
@Override public void save(ArmorCatalog catalog)throws java.io.IOException{
if(++saves==3)throw new java.io.IOException("injected settlement failure");
durable.save(catalog);
}
}
try(var characters=new CharacterStorage(characterRepository,clock)){
characters.create(character).join();
var failing=new FailingCatalogs();
try(var market=new ArmorMarket(failing,new ArmorPurchaseOperationStore(data),characters,settings::rules,
clock,(minimum,maximum)->minimum)){
var catalog=market.current().toCompletableFuture().join();
var product=new ArmorProduct(GearSlot.BOOTS,GearTier.LEATHER);
assertThrows(java.util.concurrent.CompletionException.class,()->market.purchase(character.id(),
character.revision(),catalog.revision(),product).toCompletableFuture().join());
assertEquals(ArmorMarket.PurchaseStatus.FAILED,market.purchase(character.id(),character.revision(),
catalog.revision(),product).toCompletableFuture().join().status(),
"Unresolved persistence must suspend later market mutations");
}
}
try(var characters=new CharacterStorage(characterRepository,clock);
var recovered=new ArmorMarket(durable,new ArmorPurchaseOperationStore(data),characters,settings::rules,
clock,(minimum,maximum)->maximum)){
var catalog=recovered.current().toCompletableFuture().join();
var saved=characters.find(character.id()).join().orElseThrow();
assertEquals(java.math.BigInteger.valueOf(98),saved.coins());
assertEquals(GearTier.LEATHER,saved.gear().boots());
assertEquals(24,catalog.offer(new ArmorProduct(GearSlot.BOOTS,GearTier.LEATHER)).remaining());
assertTrue(catalog.transactions().isEmpty());
}finally{settings.close();}
}
@Test
void staleCatalogGenerationRefusesWithoutCharacterOrStockMutation() throws Exception {
var settings=new GearSettings(data,Map.of());var clock=Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC);
var repository=new YamlCharacterRepository(data);var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var character=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,java.math.BigInteger.valueOf(100),true,java.util.Set.of(),java.math.BigInteger.ZERO,CharacterGear.none());
try(var characters=new CharacterStorage(repository,clock);var market=new ArmorMarket(new YamlArmorCatalogRepository(data),
new ArmorPurchaseOperationStore(data),characters,settings::rules,clock,(minimum,maximum)->minimum)){
characters.create(character).join();var old=market.current().toCompletableFuture().join();
var current=market.restock().toCompletableFuture().join();var product=new ArmorProduct(GearSlot.BOOTS,GearTier.COPPER);
var outcome=market.purchase(character.id(),character.revision(),old.revision(),product).toCompletableFuture().join();
assertEquals(ArmorMarket.PurchaseStatus.STALE,outcome.status());
assertEquals(current.offer(product),outcome.catalog().offer(product));
assertEquals(character,characters.find(character.id()).join().orElseThrow());
}finally{settings.close();}
}
@Test
void insufficientAndInvalidUpgradesRestoreReservedStockWithoutCharging() throws Exception {
var settings=new GearSettings(data,Map.of("gear-stock-diamond-min","1","gear-stock-diamond-max","1"));
var clock=Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC);
var repository=new YamlCharacterRepository(data);
var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var character=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,java.math.BigInteger.ZERO,true,java.util.Set.of(),
java.math.BigInteger.ZERO,CharacterGear.none());
try(var characters=new CharacterStorage(repository,clock);
var market=new ArmorMarket(new YamlArmorCatalogRepository(data),new ArmorPurchaseOperationStore(data),
characters,settings::rules,clock,(minimum,maximum)->minimum)){
characters.create(character).join();var catalog=market.current().toCompletableFuture().join();
var product=new ArmorProduct(GearSlot.LEGGINGS,GearTier.DIAMOND);
var outcome=market.purchase(character.id(),character.revision(),catalog.revision(),product).toCompletableFuture().join();
assertEquals(ArmorMarket.PurchaseStatus.INSUFFICIENT,outcome.status());
assertEquals(1,outcome.catalog().offer(product).remaining());
assertEquals(java.math.BigInteger.ZERO,characters.find(character.id()).join().orElseThrow().coins());
assertTrue(outcome.catalog().transactions().isEmpty());
}finally{settings.close();}
}
@Test
void repeatedConcurrentRequestsChargeAndDecrementOnlyOnce() throws Exception {
var settings=new GearSettings(data,Map.of());var clock=Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC);
var repository=new YamlCharacterRepository(data);var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var character=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,java.math.BigInteger.valueOf(100),true,java.util.Set.of(),java.math.BigInteger.ZERO,CharacterGear.none());
try(var characters=new CharacterStorage(repository,clock);var market=new ArmorMarket(new YamlArmorCatalogRepository(data),
new ArmorPurchaseOperationStore(data),characters,settings::rules,clock,(minimum,maximum)->minimum)){
characters.create(character).join();var catalog=market.current().toCompletableFuture().join();
var product=new ArmorProduct(GearSlot.LEGGINGS,GearTier.LEATHER);int initial=catalog.offer(product).remaining();
var first=market.purchase(character.id(),character.revision(),catalog.revision(),product).toCompletableFuture();
var repeat=market.purchase(character.id(),character.revision(),catalog.revision(),product).toCompletableFuture();
assertEquals(java.util.Set.of(ArmorMarket.PurchaseStatus.APPLIED,ArmorMarket.PurchaseStatus.STALE),
java.util.Set.of(first.join().status(),repeat.join().status()));
assertEquals(initial-1,market.current().toCompletableFuture().join().offer(product).remaining());
assertEquals(java.math.BigInteger.valueOf(98),characters.find(character.id()).join().orElseThrow().coins());
}finally{settings.close();}
}
@Test
void concurrentBuyersCannotOversellOneSharedRemainingItem() throws Exception {
var settings=new GearSettings(data,Map.of("gear-stock-leather-min","1","gear-stock-leather-max","1"));
var clock=Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC);
var repository=new YamlCharacterRepository(data);
var first=new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var second=new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
first=new CharacterRecord(first.id(),first.name(),first.revision(),first.stats(),first.level(),first.experience(),
first.unspentPoints(),first.health(),first.learned(),first.loadout(),first.passives(),first.cooldowns(),
first.restrictedKills(),false,null,null,java.math.BigInteger.valueOf(100),true,java.util.Set.of(),
java.math.BigInteger.ZERO,CharacterGear.none());
second=new CharacterRecord(second.id(),second.name(),second.revision(),second.stats(),second.level(),second.experience(),
second.unspentPoints(),second.health(),second.learned(),second.loadout(),second.passives(),second.cooldowns(),
second.restrictedKills(),false,null,null,java.math.BigInteger.valueOf(100),true,java.util.Set.of(),
java.math.BigInteger.ZERO,CharacterGear.none());
try(var characters=new CharacterStorage(repository,clock);
var market=new ArmorMarket(new YamlArmorCatalogRepository(data),new ArmorPurchaseOperationStore(data),
characters,settings::rules,clock,(minimum,maximum)->minimum)){
characters.create(first).join();characters.create(second).join();
var catalog=market.current().toCompletableFuture().join();
var product=new ArmorProduct(GearSlot.CHESTPLATE,GearTier.LEATHER);
var one=market.purchase(first.id(),first.revision(),catalog.revision(),product).toCompletableFuture();
var two=market.purchase(second.id(),second.revision(),catalog.revision(),product).toCompletableFuture();
var statuses=java.util.Set.of(one.join().status(),two.join().status());
assertEquals(java.util.Set.of(ArmorMarket.PurchaseStatus.APPLIED,ArmorMarket.PurchaseStatus.SOLD_OUT),statuses);
assertEquals(0,market.current().toCompletableFuture().join().offer(product).remaining());
}finally{settings.close();}
}
@Test
void purchaseRecoverablyConsumesSharedStockAndAtomicallyChargesAndUpgradesCharacter() throws Exception {
var settings = new GearSettings(data, Map.of());
var clock = Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"), ZoneOffset.UTC);
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var character = 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, java.math.BigInteger.valueOf(100), true,
java.util.Set.of(), java.math.BigInteger.ZERO, CharacterGear.none());
try (var characters = new CharacterStorage(repository, clock);
var market = new ArmorMarket(new YamlArmorCatalogRepository(data),
new ArmorPurchaseOperationStore(data), characters, settings::rules, clock,
(minimum, maximum) -> minimum)) {
characters.create(character).join();
var catalog = market.current().toCompletableFuture().join();
var product = new ArmorProduct(GearSlot.BOOTS, GearTier.LEATHER);
var outcome = market.purchase(character.id(), character.revision(), catalog.revision(), product)
.toCompletableFuture().join();
assertEquals(ArmorMarket.PurchaseStatus.APPLIED, outcome.status());
assertEquals(java.math.BigInteger.valueOf(98), outcome.record().orElseThrow().coins());
assertEquals(GearTier.LEATHER, outcome.record().orElseThrow().gear().boots());
assertEquals(24, outcome.catalog().offer(product).remaining());
assertTrue(outcome.catalog().transactions().isEmpty());
} finally {
settings.close();
}
}
@Test
void startupAfterMissedDaysGeneratesOnlyTodaysReplacement() throws Exception {
var settings=new GearSettings(data,Map.of());var clock=new MutableClock(Instant.parse("2026-09-21T12:00:00Z"));
UUID firstRevision;
try(var characters=new CharacterStorage(new YamlCharacterRepository(data),clock);var market=new ArmorMarket(
new YamlArmorCatalogRepository(data),new ArmorPurchaseOperationStore(data),characters,settings::rules,clock,
(minimum,maximum)->minimum)){firstRevision=market.current().toCompletableFuture().join().revision();}
clock.set(Instant.parse("2026-09-24T08:00:00Z"));
try(var characters=new CharacterStorage(new YamlCharacterRepository(data),clock);var market=new ArmorMarket(
new YamlArmorCatalogRepository(data),new ArmorPurchaseOperationStore(data),characters,settings::rules,clock,
(minimum,maximum)->maximum)){
var current=market.current().toCompletableFuture().join();
assertEquals(java.time.LocalDate.of(2026,9,24),current.day());
assertEquals(ArmorCatalog.RefreshReason.STARTUP,current.reason());
assertNotEquals(firstRevision,current.revision());
assertEquals(18,current.products().size());
}finally{settings.close();}
}
@Test
void startupPersistsOneCurrentUtcCatalogAndRestartDoesNotRerollOrRefillIt() throws Exception {
var settings = new GearSettings(data, Map.of());
var clock = Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"), ZoneOffset.UTC);
ArmorCatalog first;
try (var characters = new CharacterStorage(new YamlCharacterRepository(data), clock);
var market = new ArmorMarket(new YamlArmorCatalogRepository(data),
new ArmorPurchaseOperationStore(data), characters, settings::rules, clock,
(minimum, maximum) -> minimum)) {
first = market.current().toCompletableFuture().join();
assertEquals(java.time.LocalDate.of(2026, 9, 21), first.day());
assertEquals(ArmorCatalog.RefreshReason.STARTUP, first.reason());
}
try (var characters = new CharacterStorage(new YamlCharacterRepository(data), clock);
var market = new ArmorMarket(new YamlArmorCatalogRepository(data),
new ArmorPurchaseOperationStore(data), characters, settings::rules, clock,
(minimum, maximum) -> maximum)) {
assertEquals(first, market.current().toCompletableFuture().join());
} finally {
settings.close();
}
}
}