From f9dd73c18f5637bb827ad454655121c76a30faaf Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:05:09 +0000 Subject: [PATCH 1/5] feat: bind game money to character and guild ledgers --- pom.xml | 6 +- .../java/net/tfminecraft/games/Games.java | 2 + .../games/guild/GuildTableMigration.java | 38 +++ .../tfminecraft/games/guild/GuildTables.java | 53 +++- .../games/persistence/DurableFiles.java | 46 ++++ .../net/tfminecraft/games/table/Table.java | 20 ++ .../tfminecraft/games/table/TableHouse.java | 12 + .../tfminecraft/games/table/TableManager.java | 93 ++++++- .../net/tfminecraft/games/wager/Accounts.java | 5 - .../tfminecraft/games/wager/BankAccount.java | 41 ++- .../games/wager/BucketAccount.java | 22 ++ .../tfminecraft/games/wager/CitizenTax.java | 68 ++--- .../tfminecraft/games/wager/GuildBank.java | 121 ++------ .../tfminecraft/games/wager/MoneyAccount.java | 6 + .../net/tfminecraft/games/wager/MoneyTx.java | 150 +++++++++- .../games/wager/PlayerAccount.java | 43 ++- .../net/tfminecraft/games/wager/TaxSink.java | 65 ++--- .../tfminecraft/games/wager/WagerEngine.java | 93 ++++--- .../tfminecraft/games/wager/WagerJournal.java | 116 ++++++++ src/main/resources/plugin.yml | 4 +- .../games/guild/GuildTableMigrationTest.java | 81 ++++++ .../games/guild/GuildTablesTest.java | 60 +++- .../games/support/TestCharacters.java | 30 ++ .../games/table/TableManagerActionTest.java | 5 +- .../table/TableManagerBlackjackRoundTest.java | 18 +- .../table/TableManagerChipLayoutTest.java | 2 + .../games/table/TableManagerFixture.java | 10 + .../table/TableManagerInteractionTest.java | 4 +- .../table/TableManagerLifecycleTest.java | 3 + .../table/TableManagerPersistenceTest.java | 42 ++- .../games/table/TableManagerSettleTest.java | 18 +- .../table/TableManagerStoredDataTest.java | 3 + .../games/table/TableManagerWagerTest.java | 28 +- .../tfminecraft/games/wager/AccountsTest.java | 6 +- .../games/wager/BankAccountTest.java | 23 +- .../wager/CitizenTaxIntegrationTest.java | 57 ++-- .../games/wager/CitizenTaxTest.java | 6 +- .../games/wager/GuildBankTest.java | 187 +++++-------- .../tfminecraft/games/wager/MoneyTxTest.java | 1 + .../games/wager/WagerEngineTest.java | 102 +++++-- .../games/wager/WagerJournalTest.java | 258 ++++++++++++++++++ 41 files changed, 1459 insertions(+), 489 deletions(-) create mode 100644 src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java create mode 100644 src/main/java/net/tfminecraft/games/persistence/DurableFiles.java create mode 100644 src/main/java/net/tfminecraft/games/wager/WagerJournal.java create mode 100644 src/test/java/net/tfminecraft/games/guild/GuildTableMigrationTest.java create mode 100644 src/test/java/net/tfminecraft/games/support/TestCharacters.java create mode 100644 src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java diff --git a/pom.xml b/pom.xml index b1e48fd..d622583 100644 --- a/pom.xml +++ b/pom.xml @@ -12,10 +12,10 @@ 21 UTF-8 full - 0.2.0 + 0.3.0 2.16.0 - 3.0.1 - 2.3.0 + 4.0.0 + 2.4.0 diff --git a/src/main/java/net/tfminecraft/games/Games.java b/src/main/java/net/tfminecraft/games/Games.java index 4ea5d20..34dfa05 100644 --- a/src/main/java/net/tfminecraft/games/Games.java +++ b/src/main/java/net/tfminecraft/games/Games.java @@ -37,6 +37,7 @@ public class Games extends JavaPlugin { @Override public void onEnable() { plugin = this; + net.tfminecraft.games.wager.WagerJournal.initialize(); createFolders(); createConfigs(); if (!loadConfigs()) { @@ -49,6 +50,7 @@ public void onEnable() { getServer().getPluginManager().registerEvents(GameSelectGui.INSTANCE, this); getServer().getPluginManager().registerEvents(TableOptionsGui.INSTANCE, this); TableManager.get().loadAll(); + getServer().getScheduler().runTaskTimer(this, () -> TableManager.get().loadDeferred(), 100L, 100L); TableManager.get().startClock(); var gameCmd = getCommand("games"); diff --git a/src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java b/src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java new file mode 100644 index 0000000..0debb84 --- /dev/null +++ b/src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java @@ -0,0 +1,38 @@ +package net.tfminecraft.games.guild; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.tfminecraft.games.persistence.DurableFiles; + +/** Stamp an old table's guild incarnation once; a deleted guild never adopts a later namesake. */ +public final class GuildTableMigration { + private GuildTableMigration() {} + + public static boolean prepare(Path tableFile, Path migrationFolder) throws IOException { + JsonObject data = JsonParser.parseString(Files.readString(tableFile)).getAsJsonObject(); + if (data.has("ownerGuildMigrated") && data.get("ownerGuildMigrated").getAsBoolean()) return true; + if (!data.has("ownerGuildId") || data.get("ownerGuildId").isJsonNull() + || data.get("ownerGuildId").getAsString().isBlank()) return true; + if (!GuildTables.ready()) return false; + Path backup = migrationFolder.resolve("backup").resolve(tableFile.getFileName()); + Path manifest = migrationFolder.resolve(tableFile.getFileName()); + JsonObject migrated; + if (Files.exists(manifest)) { + // A complete plan is written before the table. Replay the exact incarnation, never resolve again. + migrated = JsonParser.parseString(Files.readString(manifest)).getAsJsonObject(); + } else { + if (!Files.exists(backup)) DurableFiles.write(backup, Files.readAllBytes(tableFile)); + migrated = data.deepCopy(); + UUID incarnation = GuildTables.incarnation(data.get("ownerGuildId").getAsString()); + if (incarnation != null) migrated.addProperty("ownerGuildIncarnation", incarnation.toString()); + migrated.addProperty("ownerGuildMigrated", true); + DurableFiles.write(manifest, migrated.toString()); + } + DurableFiles.write(tableFile, migrated.toString()); + return true; + } +} diff --git a/src/main/java/net/tfminecraft/games/guild/GuildTables.java b/src/main/java/net/tfminecraft/games/guild/GuildTables.java index a89ec54..be2ce70 100644 --- a/src/main/java/net/tfminecraft/games/guild/GuildTables.java +++ b/src/main/java/net/tfminecraft/games/guild/GuildTables.java @@ -1,12 +1,13 @@ package net.tfminecraft.games.guild; +import java.util.UUID; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.managers.FactionManager; -import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.api.SimpleFactionsApi; import net.tfminecraft.simplefactions.enums.GuildModifier; import net.tfminecraft.games.Games; import net.tfminecraft.games.Messages; @@ -53,7 +54,7 @@ public static String guildId(Player player) { if (!sfReady()) { return null; } - Guild guild = FactionManager.getGuildByMember(player.getName()); + Guild guild = FactionManager.getGuildByMember(player); if (guild == null) { return null; } @@ -100,8 +101,10 @@ public static int cap(String guildId) { public static int count(String guildId) { int n = 0; + UUID current = incarnation(guildId); for (Table table : TableManager.get().tables()) { - if (counts(table) && guildId.equals(table.ownerGuildId())) { + if (counts(table) && guildId.equals(table.ownerGuildId()) + && current != null && current.equals(table.ownerGuildIncarnation())) { n++; } } @@ -117,10 +120,12 @@ public static boolean overCap(String guildId) { } public static boolean frozen(Table table) { - return counts(table) && overCap(table.ownerGuildId()); + return counts(table) && (!owns(table.ownerGuildId(), table.ownerGuildIncarnation()) + || overCap(table.ownerGuildId())); } public static boolean canStartGuildAutoRound(Table table) { + if (counts(table) && !owns(table.ownerGuildId(), table.ownerGuildIncarnation())) return false; if (table.live()) { return true; } @@ -145,7 +150,9 @@ public static String refuseKey(Player player, TableHouse house, Table existing) if (id == null) { return "place.no_guild"; } - if (!isLeader(id, player)) { + if (existing == null && house.ownerGuildIncarnation() == null) + house.setOwnerGuildIncarnation(incarnation(id)); + if (!owns(id, house.ownerGuildIncarnation()) || !isLeader(id, player)) { return "place.not_leader"; } if (existing != null && counts(existing)) { @@ -174,6 +181,7 @@ public static void stampGuild(Player player, TableHouse house) { } if (house.ownerGuildId() == null || house.ownerGuildId().isBlank()) { house.setOwnerGuildId(guildId(player)); + house.setOwnerGuildIncarnation(incarnation(house.ownerGuildId())); } } @@ -186,7 +194,7 @@ public static boolean mayDeal(Table table, Player player) { } String guildId = table.ownerGuildId(); if (guildId != null && !guildId.isBlank()) { - return isMember(guildId, player); + return owns(guildId, table.ownerGuildIncarnation()) && isMember(guildId, player); } return table.ownerPlayer() != null && table.ownerPlayer().equals(player.getUniqueId()); } @@ -194,7 +202,31 @@ public static boolean mayDeal(Table table, Player player) { /** True for the leader of the guild that owns this table, who is the one who may place and remove it. */ public static boolean leadsOwner(Table table, Player player) { String guildId = table.ownerGuildId(); - return guildId != null && !guildId.isBlank() && isLeader(guildId, player); + return owns(guildId, table.ownerGuildIncarnation()) && isLeader(guildId, player); + } + + public static UUID incarnation(String guildId) { + if (guildId == null || guildId.isBlank()) return null; + try { + Guild guild = guild(guildId); + return guild == null ? null : guild.getIncarnationId(); + } catch (RuntimeException | LinkageError ex) { + warn(ex); + return null; + } + } + + public static boolean owns(String guildId, UUID incarnation) { + return incarnation != null && incarnation.equals(incarnation(guildId)); + } + + public static boolean ready() { + try { + return sfReady(); + } catch (RuntimeException | LinkageError ex) { + warn(ex); + return false; + } } private static boolean isMember(String guildId, Player player) { @@ -213,8 +245,7 @@ private static boolean isLeader(String guildId, Player player) { if (guild == null) { return false; } - String leader = guild.getLeader(); - return leader != null && leader.equalsIgnoreCase(player.getName()); + return guild.isLeader(player); } catch (RuntimeException | LinkageError ex) { warn(ex); return false; @@ -233,13 +264,13 @@ private static Guild guild(String guildId) { private static boolean sfReady() { Plugin plugin = Bukkit.getPluginManager().getPlugin("SimpleFactions"); - return plugin != null && plugin.isEnabled(); + return plugin != null && plugin.isEnabled() && SimpleFactionsApi.ready(); } private static void warn(Throwable ex) { if (!loggedFail) { loggedFail = true; - Games.plugin.getLogger().warning("[Games] SimpleFactions guild tables skipped: " + ex.getMessage()); + if (Games.plugin != null) Games.plugin.getLogger().warning("[Games] SimpleFactions guild tables skipped: " + ex.getMessage()); } } } diff --git a/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java b/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java new file mode 100644 index 0000000..dc8679d --- /dev/null +++ b/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java @@ -0,0 +1,46 @@ +package net.tfminecraft.games.persistence; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; + +/** Publish whole, flushed files before money or inventory changes. */ +public final class DurableFiles { + private DurableFiles() {} + + public static void write(Path target, String text) throws IOException { + write(target, text.getBytes(StandardCharsets.UTF_8)); + } + + public static void write(Path target, byte[] bytes) throws IOException { + Files.createDirectories(target.getParent()); + Path temp = Files.createTempFile(target.getParent(), ".pending-", ".tmp"); + try { + try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) channel.write(buffer); + channel.force(true); + } + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + sync(target.getParent()); + } finally { + Files.deleteIfExists(temp); + } + } + + public static void delete(Path target) throws IOException { + Files.deleteIfExists(target); + sync(target.getParent()); + } + + private static void sync(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } +} diff --git a/src/main/java/net/tfminecraft/games/table/Table.java b/src/main/java/net/tfminecraft/games/table/Table.java index bf2ad38..6a5c6ff 100644 --- a/src/main/java/net/tfminecraft/games/table/Table.java +++ b/src/main/java/net/tfminecraft/games/table/Table.java @@ -60,7 +60,15 @@ public final class Table { private int maxBet; private int autoCountdown; private UUID ownerPlayer; + private boolean moneyBlocked; + private final java.util.Map wagerCharacters = new java.util.HashMap<>(); + + public boolean moneyBlocked() { return moneyBlocked; } + public void setMoneyBlocked(boolean blocked) { moneyBlocked = blocked; } + public java.util.Map wagerCharacters() { return wagerCharacters; } + private String ownerGuildId; + private UUID ownerGuildIncarnation; private boolean autoDealer; private boolean staffMint; private int houseFloat; @@ -373,9 +381,18 @@ public String ownerGuildId() { } public void setOwnerGuildId(String ownerGuildId) { + if (!java.util.Objects.equals(this.ownerGuildId, ownerGuildId)) ownerGuildIncarnation = null; this.ownerGuildId = ownerGuildId; } + public UUID ownerGuildIncarnation() { + return ownerGuildIncarnation; + } + + public void setOwnerGuildIncarnation(UUID incarnation) { + this.ownerGuildIncarnation = incarnation; + } + public boolean autoDealer() { return autoDealer; } @@ -481,6 +498,7 @@ public void startSession() { } public void clearSession() { + if (moneyBlocked || net.tfminecraft.games.wager.WagerJournal.blocked(this)) return; live = false; actor = null; phase = null; @@ -489,5 +507,7 @@ public void clearSession() { handIndex = 0; autoCountdown = 0; if (!poker.enabled() || !poker.occupied()) roundMoney.clear(); + wagerCharacters.entrySet().removeIf(entry -> ledger.total(entry.getKey()) == 0 + && !(ledger.total(id) > 0 && (entry.getKey().equals(dealerId) || entry.getKey().equals(floatOwner)))); } } diff --git a/src/main/java/net/tfminecraft/games/table/TableHouse.java b/src/main/java/net/tfminecraft/games/table/TableHouse.java index 7a56851..f97eed3 100644 --- a/src/main/java/net/tfminecraft/games/table/TableHouse.java +++ b/src/main/java/net/tfminecraft/games/table/TableHouse.java @@ -15,6 +15,7 @@ public final class TableHouse { private UUID ownerPlayer; private String ownerGuildId; + private UUID ownerGuildIncarnation; private boolean autoDealer; private boolean staffMint; private int minBet; @@ -53,6 +54,7 @@ public static TableHouse from(Table table) { TableHouse house = new TableHouse(); house.ownerPlayer = table.ownerPlayer(); house.ownerGuildId = table.ownerGuildId(); + house.ownerGuildIncarnation = table.ownerGuildIncarnation(); house.autoDealer = table.autoDealer(); house.staffMint = table.staffMint(); house.minBet = Math.max(1, table.minBet()); @@ -67,6 +69,7 @@ public static TableHouse from(Table table) { public void apply(Table table) { table.setOwnerPlayer(ownerPlayer); table.setOwnerGuildId(ownerGuildId); + table.setOwnerGuildIncarnation(ownerGuildIncarnation); table.setAutoDealer(autoDealer); table.setStaffMint(staffMint); table.setMinBet(minBet); @@ -90,9 +93,18 @@ public String ownerGuildId() { } public void setOwnerGuildId(String ownerGuildId) { + if (!java.util.Objects.equals(this.ownerGuildId, ownerGuildId)) ownerGuildIncarnation = null; this.ownerGuildId = ownerGuildId; } + public UUID ownerGuildIncarnation() { + return ownerGuildIncarnation; + } + + public void setOwnerGuildIncarnation(UUID incarnation) { + this.ownerGuildIncarnation = incarnation; + } + public boolean autoDealer() { return autoDealer; } diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index 7a00ffa..f6a2b51 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -272,24 +272,47 @@ public void selectGame(Player player, String gameId, Location at, boolean requir player.sendMessage(Messages.get(requireDeck ? "place.armed" : "place.armed_admin")); } - public void loadAll() { + public void loadAll() { loadTables(false); } + + public void loadDeferred() { loadTables(true); } + + private void loadTables(boolean onlyMissing) { File folder = tablesFolder(); File[] files = folder.listFiles((dir, name) -> name.endsWith(".json")); if (files == null) { return; } for (File file : files) { + try { + if (onlyMissing) { + try { + UUID fileId = UUID.fromString(file.getName().replace(".json", "")); + if (tables.containsKey(fileId)) continue; + } catch (IllegalArgumentException ignored) { /* Older/imported files may have another filename. */ } + } + if (!net.tfminecraft.games.guild.GuildTableMigration.prepare(file.toPath(), + new File(Games.plugin.getDataFolder(), "migrations/guild-tables").toPath())) continue; + } catch (IOException | RuntimeException ex) { + Games.plugin.getLogger().warning("[Games] Failed to load table " + file.getName() + ": " + ex); + continue; + } try (FileReader reader = new FileReader(file, StandardCharsets.UTF_8)) { TableData data = GSON.fromJson(reader, TableData.class); if (data == null || data.id == null) { continue; } + if (onlyMissing && tables.containsKey(playerId(data.id))) continue; List unowned = new ArrayList<>(); Table table = fromData(data, unowned); if (table == null) { continue; } tables.put(table.getId(), table); + if (net.tfminecraft.games.wager.WagerJournal.blocked(table)) { + table.setMoneyBlocked(true); + Games.plugin.getLogger().severe("[Games] Interrupted money operation freezes table " + table.getId()); + continue; + } migrateLegacyPiles(table, data, unowned); int unownedDenars = unowned.stream().mapToInt(Stake::value).sum(); LedgerAudit.checkLoaded(table, storedDenars(data), unownedDenars); @@ -330,6 +353,7 @@ public void despawnWorldAll() { * End the session, settle money, muck cards, full idle deck. Table file stays. */ private void resetTableToIdle(Table table, Location dropAt) { + if (table.moneyBlocked() || net.tfminecraft.games.wager.WagerJournal.blocked(table)) return; cancelVote(table, null); cancelLootArmsForTable(table.getId()); table.bumpRecycleGen(); @@ -350,7 +374,9 @@ private void resetTableToIdle(Table table, Location dropAt) { // being dropped at an empty table on shutdown. settleAbsentFloat(table); } + if (moneyBlocked(table)) return; clearFeltNow(table); + if (moneyBlocked(table)) return; table.actives().clear(); table.setDealerId(null); table.setStreet(1); @@ -725,12 +751,15 @@ public void onChunkLoad(ChunkLoadEvent event) { } private void pickup(Player player, Table table) { + if (table.moneyBlocked() || net.tfminecraft.games.wager.WagerJournal.blocked(table)) return; cancelVote(table, "wager.cancelled"); table.bumpRecycleGen(); cancelLootArmsForTable(table.getId()); Location dropAt = table.getOrigin().clone(); settleAutoTray(table, dropAt); + if (moneyBlocked(table)) return; clearFeltNow(table); + if (moneyBlocked(table)) return; returnAllHands(table, false, false); table.actives().clear(); table.clearSession(); @@ -1587,12 +1616,24 @@ public void bankAutoTray(Table table) { } public void beginSession(Table table) { + if (table.moneyBlocked() || net.tfminecraft.games.wager.WagerJournal.blocked(table) || !net.tfminecraft.denareconomy.api.Denars.taxReady()) return; if (table.live()) { return; } if (!GuildTables.canStartGuildAutoRound(table)) { return; } + java.util.Set participants = new java.util.LinkedHashSet<>(table.actives()); + if (table.dealerId() != null) participants.add(table.dealerId()); + for (UUID owner : participants) { + Player active = Bukkit.getPlayer(owner); + var captured = table.wagerCharacters().get(owner); + var current = active == null ? null : net.tfminecraft.tlibs.character.Characters.currentSession(active) + .map(session -> session.ref()).orElse(null); + if (current == null && captured == null) return; + if (current != null && captured != null && !current.equals(captured)) return; + if (current != null) table.wagerCharacters().putIfAbsent(owner, current); + } table.startSession(); Game game = gameOf(table); if (game != null) { @@ -1756,6 +1797,7 @@ public void muckTable(Table table, String name) { } public void endSession(Table table) { + if (table.moneyBlocked() || net.tfminecraft.games.wager.WagerJournal.blocked(table)) return; table.clearSession(); checkFeltEmpty(table, "session end"); despawnTablePiles(table); @@ -1809,9 +1851,11 @@ private void dealRemaining(Table table, Player player, int left, int slot, Runna } public boolean canEditHouse(Player player, Table table) { + if (table.moneyBlocked() || net.tfminecraft.games.wager.WagerJournal.blocked(table)) return false; if (player.hasPermission(TableHouse.STAFF_PERM) || player.hasPermission("games.admin")) { return true; } + if (GuildTables.counts(table)) return GuildTables.leadsOwner(table, player); UUID owner = table.ownerPlayer(); return owner != null && owner.equals(player.getUniqueId()); } @@ -1867,8 +1911,10 @@ public void persistHouseChange(Table table) { public void applyHouse(Table table, TableHouse house) { String wasGuild = table.ownerGuildId(); + UUID wasIncarnation = table.ownerGuildIncarnation(); house.apply(table); - if (!Objects.equals(wasGuild, table.ownerGuildId())) { + if (!Objects.equals(wasGuild, table.ownerGuildId()) + || !Objects.equals(wasIncarnation, table.ownerGuildIncarnation())) { // A float belongs to the guild that put it up, so it cannot follow the table to a new // owner and shelter their winnings from tax. table.setHouseFloat(0); @@ -1904,6 +1950,13 @@ private static void applyHouseData(Table table, TableData data) { table.setFloatOwner(playerId(data.floatOwner)); table.setDeckConsumed(!Boolean.FALSE.equals(data.deckConsumed)); table.setOwnerGuildId(data.ownerGuildId); + table.setOwnerGuildIncarnation(playerId(data.ownerGuildIncarnation)); + if (data.wagerCharacters != null) data.wagerCharacters.forEach((owner, character) -> { + UUID ownerId = playerId(owner); + UUID characterId = playerId(character); + if (ownerId != null && characterId != null) table.wagerCharacters().put(ownerId, + new net.tfminecraft.tlibs.character.CharacterRef(ownerId, characterId)); + }); table.setHouseFloat(data.houseFloat != null ? data.houseFloat : 0); if (data.autoDealer == null) { boolean yaml = layout != null && layout.autoDealer(); @@ -3071,24 +3124,39 @@ private void clearFeltNow(Table table) { * Empty the tray. Bank money the house put there, give a human dealer their own float back, * and delete a staff mint float. */ + private static boolean moneyBlocked(Table table) { + return table.moneyBlocked() || net.tfminecraft.games.wager.WagerJournal.blocked(table); + } + private void settleAutoTray(Table table, Location dropAt) { UUID house = table.getId(); if (table.ledger().total(house) < 1) { return; } + if (GuildTables.counts(table) && (!GuildTables.owns(table.ownerGuildId(), table.ownerGuildIncarnation()) + || !net.tfminecraft.denareconomy.api.Denars.taxReady())) { + net.tfminecraft.games.wager.WagerJournal.quarantine(table, "Unresolved guild tray settlement: guild=" + + table.ownerGuildId() + ", incarnation=" + table.ownerGuildIncarnation() + ", value=" + trayDenars(table)); + return; + } MoneyAccount tray = Accounts.tray(table); MoneyAccount to; String reason; if (!GuildTables.houseBacked(table)) { // Nothing behind this table but its dealer, so the float they stocked goes back to them. Player dealer = table.dealerId() != null ? Bukkit.getPlayer(table.dealerId()) : null; - to = Accounts.payee(table, dealer, house); + to = dealer == null ? Accounts.ground(table, dropAt) : Accounts.payee(table, dealer, dealer.getUniqueId()); reason = "dealer float returned"; } else { to = Accounts.house(table); reason = "tray settled"; } TxResult result = wager().begin(table, reason).moveAll(tray, to).commit(); + if (!result.ok() && GuildTables.counts(table)) { + net.tfminecraft.games.wager.WagerJournal.quarantine(table, "Unpaid guild tray settlement: guild=" + + table.ownerGuildIncarnation() + ", value=" + trayDenars(table)); + return; + } if (!result.ok() || result.moved() < 1) { // Nowhere to bank it, so it goes back out as coins rather than being lost. wager().begin(table, "tray dropped, no guild bank") @@ -3127,10 +3195,10 @@ private void settleAbsentFloat(Table table) { /** The whole tray to one player who is online, and nobody owed it any more. */ private void payTray(Table table, Player to) { - table.setFloatOwner(null); - wager().begin(table, "dealer float returned") + TxResult paid = wager().begin(table, "dealer float returned") .moveAll(Accounts.tray(table), Accounts.payee(table, to, to.getUniqueId())) .commit(); + if (paid.ok() && trayDenars(table) == 0) table.setFloatOwner(null); wager().forget(table, table.getId()); save(table); } @@ -4369,11 +4437,15 @@ private Table tableFrom(Entity entity) { } } + public void saveWager(Table table) throws IOException { + net.tfminecraft.games.persistence.DurableFiles.write(tableFile(table.getId()).toPath(), GSON.toJson(toData(table))); + } + private void save(Table table) { File file = tableFile(table.getId()); TableData data = toData(table); - try (FileWriter writer = new FileWriter(file, StandardCharsets.UTF_8)) { - GSON.toJson(data, writer); + try { + net.tfminecraft.games.persistence.DurableFiles.write(file.toPath(), GSON.toJson(data)); } catch (IOException ex) { Games.plugin.getLogger().warning("[Games] Failed to save table " + table.getId() + ": " + ex.getMessage()); } @@ -4427,6 +4499,10 @@ private static TableData toData(Table table) { } data.ownerPlayer = table.ownerPlayer() != null ? table.ownerPlayer().toString() : null; data.ownerGuildId = table.ownerGuildId(); + data.ownerGuildIncarnation = table.ownerGuildIncarnation() == null ? null : table.ownerGuildIncarnation().toString(); + data.ownerGuildMigrated = true; + data.wagerCharacters = new HashMap<>(); + table.wagerCharacters().forEach((owner, character) -> data.wagerCharacters.put(owner.toString(), character.characterId().toString())); data.autoDealer = table.autoDealer(); data.staffMint = table.staffMint(); data.houseFloat = table.houseFloat(); @@ -4702,6 +4778,9 @@ static final class TableData { List piles = new ArrayList<>(); String ownerPlayer; String ownerGuildId; + String ownerGuildIncarnation; + Boolean ownerGuildMigrated; + Map wagerCharacters; Boolean autoDealer; Boolean staffMint; /** Bank money still out on the felt. Absent on files written before profits were taxed. */ diff --git a/src/main/java/net/tfminecraft/games/wager/Accounts.java b/src/main/java/net/tfminecraft/games/wager/Accounts.java index a2e698f..b60c17d 100644 --- a/src/main/java/net/tfminecraft/games/wager/Accounts.java +++ b/src/main/java/net/tfminecraft/games/wager/Accounts.java @@ -82,11 +82,6 @@ public static MintAccount mint(Table table) { return new MintAccount(table, table.street()); } - /** Where withheld citizen tax chips are destroyed. Not on the felt and not in a bank. */ - public static MoneyAccount taxSink() { - return TaxSink.INSTANCE; - } - /** * Wherever this table's house money comes from: the mint on a staff table, the guild bank * on a real one. Saves every caller repeating that branch and getting it slightly different. diff --git a/src/main/java/net/tfminecraft/games/wager/BankAccount.java b/src/main/java/net/tfminecraft/games/wager/BankAccount.java index 3f57f58..0561580 100644 --- a/src/main/java/net/tfminecraft/games/wager/BankAccount.java +++ b/src/main/java/net/tfminecraft/games/wager/BankAccount.java @@ -16,12 +16,28 @@ public final class BankAccount implements MoneyAccount { private final Table table; private final int streetId; + private Integer planningBalance; + private int authorizedDebit; + private int authorizedCredit; BankAccount(Table table, int streetId) { this.table = table; this.streetId = streetId; } + @Override + public MoneyAccount planningCopy() { + BankAccount copy = new BankAccount(table, streetId); + copy.planningBalance = available(); + return copy; + } + + @Override + public String ledgerAccount() { return GuildBank.account(table); } + + void authorizeDebit(int denars) { authorizedDebit = Math.addExact(authorizedDebit, denars); } + void authorizeCredit(int denars) { authorizedCredit = Math.addExact(authorizedCredit, denars); } + @Override public String label() { return "guild bank"; @@ -29,7 +45,7 @@ public String label() { @Override public int available() { - return GuildBank.balance(table.ownerGuildId()); + return planningBalance != null ? planningBalance : GuildBank.balance(table); } @Override @@ -61,7 +77,7 @@ public int largestTakeUpTo(int denars, ItemStack template) { @Override public boolean canAccept(int denars) { - return denars < 1 || GuildBank.canHold(table.ownerGuildId()); + return denars < 1 || GuildBank.canHold(table); } @Override @@ -70,17 +86,10 @@ public int accept(List stakes) { if (value < 1) { return 0; } - if (!GuildBank.deposit(table.ownerGuildId(), value)) { - return 0; - } - // The float comes home before anything is called a win, so a table that has been losing - // has to earn its way back to level before the guild owes tax on any of it. + if (authorizedCredit < value) return 0; + authorizedCredit -= value; int repaid = Math.min(value, table.houseFloat()); table.setHouseFloat(table.houseFloat() - repaid); - int profit = value - repaid; - if (profit > 0) { - GuildBank.declareProfit(table.ownerGuildId(), profit); - } return value; } @@ -110,10 +119,14 @@ public List preview() { @Override public List take() { - if (!GuildBank.withdraw(table.ownerGuildId(), denars)) { - return new ArrayList<>(); + if (planningBalance != null) { + if (planningBalance < denars) return new ArrayList<>(); + planningBalance -= denars; + return coins(); } - table.setHouseFloat(table.houseFloat() + denars); + if (authorizedDebit < denars) return new ArrayList<>(); + authorizedDebit -= denars; + table.setHouseFloat(Math.addExact(table.houseFloat(), denars)); return coins(); } diff --git a/src/main/java/net/tfminecraft/games/wager/BucketAccount.java b/src/main/java/net/tfminecraft/games/wager/BucketAccount.java index e0f994d..328d40d 100644 --- a/src/main/java/net/tfminecraft/games/wager/BucketAccount.java +++ b/src/main/java/net/tfminecraft/games/wager/BucketAccount.java @@ -53,6 +53,28 @@ public UUID owner() { return owner; } + Object snapshot() { + java.util.List state = new java.util.ArrayList<>(); + for (Stake stake : table.ledger().stakes(owner)) { + state.add(stake.item().clone()); + state.add(stake.unit()); + state.add(stake.count()); + state.add(stake.streetId()); + } + return state; + } + + @Override + public MoneyAccount planningCopy() { + Table copy = new Table(table.getId(), table.getGameId(), null, 0, null); + for (Stake stake : table.ledger().stakes(owner)) { + copy.ledger().add(owner, stake.item().clone(), stake.typeKey(), stake.unit(), stake.count(), stake.streetId()); + } + BucketAccount account = new BucketAccount(copy, owner); + account.street = street; + return account; + } + @Override public String label() { return isTray() ? "tray" : "felt"; diff --git a/src/main/java/net/tfminecraft/games/wager/CitizenTax.java b/src/main/java/net/tfminecraft/games/wager/CitizenTax.java index d10ac59..61e6fb1 100644 --- a/src/main/java/net/tfminecraft/games/wager/CitizenTax.java +++ b/src/main/java/net/tfminecraft/games/wager/CitizenTax.java @@ -1,63 +1,51 @@ package net.tfminecraft.games.wager; +import org.bukkit.Bukkit; import org.bukkit.entity.Player; - -import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.TaxPolicy; import net.tfminecraft.denareconomy.loaders.MessageLoader; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; -/** - * Citizen tax on net coin profit from a table round. Uses DenarEconomy {@code doTaxes} so - * SimpleFactions records the levy the same way as any other earned income. - */ +/** Pure quote; only MoneyTx credits the actual chip tax that it withholds. */ public final class CitizenTax { - - /** Chips to withhold and the tax figure for the player message. */ - public record Levy(int chips, double tax) { - - public static final Levy NONE = new Levy(0, 0.0); + public record Levy(int chips, double tax, CharacterRef earner, String account) { + public static final Levy NONE = new Levy(0, 0.0, null, null); + public Levy(int chips, double tax) { this(chips, tax, null, Denars.TAX_VOID); } } private CitizenTax() {} - /** - * Work out citizen tax on {@code moneyProfit} and fire {@code PlayerEarnMoneyEvent} once. - * Returns zero when DenarEconomy is down or there is nothing to tax. - */ + /** Null means defer payment until identity and taxation are available. */ public static Levy levy(Player player, int moneyProfit) { - if (player == null || moneyProfit < 1 || !ChipItems.denarEconomyPresent()) { - return Levy.NONE; - } - double tax = DenarEconomy.getMoneyManager().doTaxes(player.getName(), moneyProfit); - return new Levy(chipsDue(moneyProfit, tax), tax); + return quote(player == null ? null : Characters.currentSession(player).map(session -> session.ref()).orElse(null), moneyProfit); + } + + public static Levy quote(CharacterRef character, int moneyProfit) { + if (moneyProfit < 1) return Levy.NONE; + if (character == null || !Denars.taxReady()) return null; + TaxPolicy policy = Bukkit.getServicesManager().load(TaxPolicy.class); + if (policy == null) return Levy.NONE; + long gross = moneyProfit * 100L; + return policy.tax(character, gross).map(tax -> { + if (tax.cents() > gross) throw new IllegalStateException("Tax exceeds the taxable profit"); + return new Levy(chipsDue(moneyProfit, tax.cents() / 100.0), tax.cents() / 100.0, character, tax.account()); + }).orElse(Levy.NONE); } - /** - * How many denars of chips to withhold from a payout for citizen tax on {@code moneyProfit}. - */ public static int due(Player player, int moneyProfit) { - return levy(player, moneyProfit).chips(); + Levy levy = levy(player, moneyProfit); + return levy == null ? 0 : levy.chips(); } - /** Same line as picking up money: {@code (%tax% in tax)} from DenarEconomy messages. */ public static void tell(Player player, double tax) { - if (player == null || !player.isOnline() || tax <= 0) { - return; - } + if (player == null || !player.isOnline() || tax <= 0) return; MessageLoader.send(player, "money.tax", "tax", tax); } - /** - * Whole denars to peel off a payout, capped at the profit being taxed. Matches DenarEconomy - * rounding before chips move. - */ static int chipsDue(int moneyProfit, double tax) { - if (tax <= 0 || Double.isNaN(tax) || Double.isInfinite(tax)) { - return 0; - } - long rounded = Math.round(tax); - if (rounded < 1) { - return 0; - } - return (int) Math.min(moneyProfit, rounded); + if (tax <= 0 || !Double.isFinite(tax)) return 0; + return (int) Math.min(moneyProfit, Math.round(tax)); } } diff --git a/src/main/java/net/tfminecraft/games/wager/GuildBank.java b/src/main/java/net/tfminecraft/games/wager/GuildBank.java index da6666e..706370f 100644 --- a/src/main/java/net/tfminecraft/games/wager/GuildBank.java +++ b/src/main/java/net/tfminecraft/games/wager/GuildBank.java @@ -1,139 +1,56 @@ package net.tfminecraft.games.wager; -import org.bukkit.Bukkit; -import org.bukkit.plugin.Plugin; - +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.games.Games; +import net.tfminecraft.games.guild.GuildTables; +import net.tfminecraft.games.table.Table; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.managers.FactionManager; -import net.tfminecraft.simplefactions.objects.Bank; -import net.tfminecraft.games.Games; -/** - * The guild bank behind a table, and the only place in the plugin that moves guild money. - * Package private on purpose: everything goes through {@link BankAccount} so a withdrawal - * cannot happen without a matching leg on the felt. - */ +/** Validated guild ledger account; compound postings belong to MoneyTx. */ final class GuildBank { - private static boolean loggedFail; - private GuildBank() {} - static boolean exists(String guildId) { - try { - return guild(guildId) != null; - } catch (RuntimeException | LinkageError ex) { - warn(ex); - return false; - } + static String account(Table table) { + return table.ownerGuildIncarnation() == null ? null : Denars.guild(table.ownerGuildIncarnation()); } - /** Denars the guild can pay out, or 0 when there is no usable bank. */ - static int balance(String guildId) { + static int balance(Table table) { try { - Guild guild = guild(guildId); - if (guild == null || guild.isBankrupt()) { - return 0; - } - Bank bank = guild.getBank(); - if (bank == null || bank.getWealth() == null) { - return 0; - } - double wealth = bank.getWealth(); - return wealth <= 0 ? 0 : (int) Math.min(Integer.MAX_VALUE, Math.floor(wealth)); + if (!canHold(table)) return 0; + return (int) Math.max(0, Math.min(Integer.MAX_VALUE, Denars.balance(account(table)) / 100)); } catch (RuntimeException | LinkageError ex) { warn(ex); return 0; } } - static boolean canHold(String guildId) { + static boolean canHold(Table table) { try { - Guild guild = guild(guildId); - return guild != null && guild.getBank() != null; + return GuildTables.owns(table.ownerGuildId(), table.ownerGuildIncarnation()) && Denars.ready(); } catch (RuntimeException | LinkageError ex) { warn(ex); return false; } } - static boolean withdraw(String guildId, int denars) { - if (denars < 1) { - return true; - } + static boolean declareProfit(Table table, String operationId, int denars) { + if (denars < 1) return true; try { - Guild guild = guild(guildId); - if (guild == null || guild.isBankrupt()) { - return false; - } - Bank bank = guild.getBank(); - if (bank == null || bank.getWealth() == null || bank.getWealth() < denars) { - return false; - } - bank.withdraw((double) denars); - return true; + if (!canHold(table)) return false; + Guild guild = FactionManager.getGuildByString(table.ownerGuildId()); + return guild != null && guild.getLedger().addCasinoProfitEntry(operationId, denars); } catch (RuntimeException | LinkageError ex) { warn(ex); return false; } } - static boolean deposit(String guildId, int denars) { - if (denars < 1) { - return true; - } - try { - Guild guild = guild(guildId); - if (guild == null) { - return false; - } - Bank bank = guild.getBank(); - if (bank == null) { - return false; - } - bank.deposit((double) denars); - return true; - } catch (RuntimeException | LinkageError ex) { - warn(ex); - return false; - } - } - - /** - * Tell the guild's ledger that this much of what was banked was won rather than handed back, - * so the day's tax is worked out on the profit only. The money itself already moved. - */ - static void declareProfit(String guildId, int denars) { - if (denars < 1) { - return; - } - try { - Guild guild = guild(guildId); - if (guild == null) { - return; - } - guild.getLedger().addCasinoProfitEntry((double) denars); - } catch (RuntimeException | LinkageError ex) { - warn(ex); - } - } - - private static Guild guild(String guildId) { - if (guildId == null || guildId.isBlank() || !ready()) { - return null; - } - return FactionManager.getGuildByString(guildId); - } - - private static boolean ready() { - Plugin plugin = Bukkit.getPluginManager().getPlugin("SimpleFactions"); - return plugin != null && plugin.isEnabled(); - } - private static void warn(Throwable ex) { - if (!loggedFail) { + if (!loggedFail && Games.plugin != null) { loggedFail = true; - Games.plugin.getLogger().warning("[Games] SimpleFactions guild bank skipped: " + ex.getMessage()); + Games.plugin.getLogger().warning("[Games] SimpleFactions guild bank unavailable: " + ex.getMessage()); } } } diff --git a/src/main/java/net/tfminecraft/games/wager/MoneyAccount.java b/src/main/java/net/tfminecraft/games/wager/MoneyAccount.java index 1a809a6..a13dc53 100644 --- a/src/main/java/net/tfminecraft/games/wager/MoneyAccount.java +++ b/src/main/java/net/tfminecraft/games/wager/MoneyAccount.java @@ -15,6 +15,12 @@ */ public interface MoneyAccount { + /** A detached source used to reserve coins while a compound transaction is planned. */ + default MoneyAccount planningCopy() { return this; } + + /** Ledger destination/source, or null for physical chips. */ + default String ledgerAccount() { return null; } + /** Short name for the audit line, like "felt" or "guild bank". */ String label(); diff --git a/src/main/java/net/tfminecraft/games/wager/MoneyTx.java b/src/main/java/net/tfminecraft/games/wager/MoneyTx.java index fb7adf2..694cd19 100644 --- a/src/main/java/net/tfminecraft/games/wager/MoneyTx.java +++ b/src/main/java/net/tfminecraft/games/wager/MoneyTx.java @@ -1,6 +1,11 @@ package net.tfminecraft.games.wager; import java.util.ArrayList; +import java.util.Base64; +import java.util.IdentityHashMap; +import java.util.Map; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.tlibs.character.CharacterRef; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -52,6 +57,12 @@ private record Planned(Leg leg, Withdrawal plan) {} private final String reason; private final List legs = new ArrayList<>(); private List flights; + private TxResult completed; + private CitizenTax.Levy taxQuote; + private String operationId; + MoneyTx taxQuote(CitizenTax.Levy quote) { this.taxQuote = quote; return this; } + String operationId() { return operationId; } + private final Map characters = new java.util.HashMap<>(); MoneyTx(WagerHost host, Table table, String reason) { this.host = host; @@ -156,16 +167,49 @@ public MoneyTx animate(List into) { /** Plan every leg, then carry them all out. Nothing moves unless all of it can. */ public TxResult commit() { + if (completed != null) return completed; + characters.clear(); + for (Leg leg : legs) if (leg.pool != null) leg.pool[0] = leg.denars; if (table == null || host == null) { return TxResult.failed(TxResult.Reason.NO_TABLE, 0, 0); } + if (table.moneyBlocked() || (WagerJournal.blocked(table) && !WagerJournal.retryable(table))) + return TxResult.failed(TxResult.Reason.BANK_SHORT, 0, 0); if (legs.isEmpty()) { return TxResult.nothing(); } List planned = new ArrayList<>(); + Map copies = new IdentityHashMap<>(); + Map sources = new java.util.HashMap<>(); for (Leg leg : legs) { - int want = wanted(leg); - Withdrawal plan = want > 0 ? plan(leg, want) : null; + String sourceKey = leg.from instanceof BucketAccount ? "bucket:" + leg.from.feltOwner() + : leg.from instanceof PlayerAccount ? "player:" + leg.from.flightTarget() + : leg.from instanceof BankAccount ? "bank:" + leg.from.ledgerAccount() : null; + if (sourceKey != null) { + MoneyAccount prior = sources.putIfAbsent(sourceKey, leg.from); + if (prior != null && prior != leg.from) return TxResult.failed(TxResult.Reason.NO_CHANGE, leg.denars, 0); + } + if (leg.to instanceof PlayerAccount recipient && recipient.flightTarget() != null) { + CharacterRef expected = table.wagerCharacters().get(recipient.flightTarget()); + if (expected == null || (recipient.online() && !expected.equals(recipient.character()))) { + WagerJournal.quarantine(table, "Payout character changed: owner=" + recipient.flightTarget() + ", expected=" + expected); + return TxResult.failed(TxResult.Reason.PLAYER_SHORT, leg.denars, 0); + } + } + if (leg.from instanceof PlayerAccount player) { + var character = player.character(); + var captured = table.wagerCharacters().get(player.flightTarget()); + if (character == null || (captured != null && !captured.equals(character))) + return TxResult.failed(TxResult.Reason.PLAYER_SHORT, leg.denars, 0); + characters.put(player.flightTarget(), character); + } + if (leg.pool != null && leg.pool[0] < 1) continue; + MoneyAccount copy = copies.computeIfAbsent(leg.from, source -> { + MoneyAccount detached = source.planningCopy(); + return detached == null ? source : detached; + }); + int want = wanted(leg, copy); + Withdrawal plan = want > 0 ? plan(leg, copy, want) : null; if (plan == null) { if (leg.mode == Mode.EXACT) { return TxResult.failed(refusal(leg, want), want, @@ -179,15 +223,58 @@ public TxResult commit() { if (leg.pool != null) { leg.pool[0] -= plan.denars(); } + if (copy != leg.from) { + List preview = plan.preview(); + int denars = plan.denars(); + if (TableLedger.valueOf(plan.take()) != denars) throw new IllegalStateException("Planning reservation failed"); + Withdrawal captured = leg.from instanceof BankAccount ? leg.from.planTake(denars, leg.template) : null; + plan = new Withdrawal() { + public int denars() { return denars; } + public List preview() { return preview; } + public List take() { + Withdrawal current = captured != null ? captured : MoneyTx.this.plan(leg, leg.from, denars); + return current == null ? List.of() : current.take(); + } + }; + } planned.add(new Planned(leg, plan)); } if (planned.isEmpty()) { return TxResult.nothing(); } - return apply(planned); + boolean ledgerBacked = planned.stream().anyMatch(step -> step.leg().from instanceof BankAccount + || step.leg().to instanceof BankAccount || step.leg().to instanceof TaxSink); + if (!ledgerBacked) { + if (WagerJournal.blocked(table)) return TxResult.failed(TxResult.Reason.BANK_SHORT, 0, 0); + return completed = apply(planned); + } + WagerJournal.Plan ledger = ledgerPlan(planned); + if (!Denars.taxReady()) return TxResult.failed(TxResult.Reason.BANK_SHORT, 0, 0); + Map snapshots = new IdentityHashMap<>(); + for (Planned step : planned) { + MoneyAccount from = step.leg().from; + if (from instanceof PlayerAccount player) snapshots.put(from, player.snapshot()); + if (from instanceof BucketAccount bucket) snapshots.put(from, bucket.snapshot()); + if (step.leg().to instanceof PlayerAccount player) snapshots.put(player, player.snapshot()); + } + TxResult result = WagerJournal.apply(table, ledger, id -> operationId = id, () -> { + for (var entry : snapshots.entrySet()) { + Object current = entry.getKey() instanceof PlayerAccount player ? player.snapshot() + : ((BucketAccount) entry.getKey()).snapshot(); + if (!entry.getValue().equals(current)) throw new IllegalStateException("Physical money or character changed during posting"); + } + for (Planned step : planned) { + if (step.leg().from instanceof BankAccount bank) bank.authorizeDebit(step.plan().denars()); + if (step.leg().to instanceof BankAccount bank) bank.authorizeCredit(step.plan().denars()); + if (step.leg().to instanceof TaxSink tax) tax.authorize(step.plan().denars()); + } + return apply(planned); + }); + if (result.ok()) completed = result; + return result; } - private int wanted(Leg leg) { + private int wanted(Leg leg, MoneyAccount source) { if (leg.mode == Mode.ALL) { return Integer.MAX_VALUE; } @@ -195,14 +282,48 @@ private int wanted(Leg leg) { if (asked < 1) { return 0; } - return leg.mode == Mode.UP_TO ? leg.from.largestTakeUpTo(asked, leg.template) : asked; + return leg.mode == Mode.UP_TO ? source.largestTakeUpTo(asked, leg.template) : asked; } - private Withdrawal plan(Leg leg, int want) { + private Withdrawal plan(Leg leg, MoneyAccount source, int want) { if (leg.mode == Mode.ALL) { - return leg.from.planTakeAll(leg.street); + return source.planTakeAll(leg.street); } - return leg.from.planTake(want, leg.template); + return source.planTake(want, leg.template); + } + + private WagerJournal.Plan ledgerPlan(List planned) { + List money = new ArrayList<>(); + List movements = new ArrayList<>(); + int nextFloat = table.houseFloat(); + int profit = 0; + for (Planned step : planned) { + Leg leg = step.leg(); + int value = step.plan().denars(); + String from = leg.from.ledgerAccount(); + String to = leg.to.ledgerAccount(); + if (from != null || to != null) { + money.add(new net.tfminecraft.denareconomy.api.Leg(from == null ? Denars.MINT : from, -value * 100L)); + money.add(new net.tfminecraft.denareconomy.api.Leg(to == null ? Denars.MINT : to, value * 100L)); + } + if (leg.from instanceof BankAccount) nextFloat = Math.addExact(nextFloat, value); + if (leg.to instanceof BankAccount) { + int repaid = Math.min(value, nextFloat); + nextFloat -= repaid; + profit = Math.addExact(profit, value - repaid); + } + List items = step.plan().preview().stream().map(stake -> + Base64.getEncoder().encodeToString(stake.item().serializeAsBytes()) + ":" + stake.unit() + + ":" + stake.count() + ":" + stake.streetId()).toList(); + movements.add(new WagerJournal.Movement(identity(leg.from), identity(leg.to), value, items, + leg.to instanceof TaxSink tax ? tax.levy() : null)); + } + return new WagerJournal.Plan(reason, money, movements, table.houseFloat(), nextFloat, profit, Map.copyOf(table.wagerCharacters()), taxQuote); + } + + private String identity(MoneyAccount account) { + return account.ledgerAccount() != null ? account.ledgerAccount() + : account.label() + ":" + account.feltOwner() + ":" + account.flightTarget(); } /** Why an exact leg could not be met, so the player gets told the real reason. */ @@ -226,6 +347,8 @@ private TxResult.Reason refusal(Leg leg, int want) { } private TxResult apply(List planned) { + boolean strict = planned.stream().anyMatch(step -> step.leg().from instanceof BankAccount + || step.leg().to instanceof BankAccount || step.leg().to instanceof TaxSink); int feltBefore = table.ledger().total(); int expected = 0; int moved = 0; @@ -237,21 +360,22 @@ private TxResult apply(List planned) { List coins = step.plan().take(); int value = TableLedger.valueOf(coins); if (value != step.plan().denars()) { - MoneyLog.mismatch(table, "planned " + step.plan().denars() + " out of " - + leg.from.label() + " but moved " + value + " (" + reason + ")"); + if (strict) throw new IllegalStateException("Planned " + step.plan().denars() + " from " + leg.from.label() + " but moved " + value); + MoneyLog.mismatch(table, "planned " + step.plan().denars() + " out of " + leg.from.label() + " but moved " + value); } if (value < 1) { continue; } int absorbed = leg.to.accept(coins); if (absorbed < value) { - // Nothing sensible to do but hand it back where it came from, and shout. - MoneyLog.mismatch(table, leg.to.label() + " only took " + absorbed + " of " + value - + ", returning it to " + leg.from.label() + " (" + reason + ")"); + if (strict) throw new IllegalStateException("Destination accepted " + absorbed + " of " + value); + MoneyLog.mismatch(table, "Destination only took " + absorbed + " of " + value + ", returning it"); leg.from.accept(coins); continue; } MoneyLog.move(table, leg.from.label(), leg.to.label(), value, reason); + if (leg.from instanceof PlayerAccount player) + table.wagerCharacters().putIfAbsent(player.flightTarget(), characters.get(player.flightTarget())); table.roundMoney().recordLeg(leg.from, leg.to, coins); if (flights != null && from != null) { flights.addAll(host.flights(table, coins, from, leg.to.flightTarget(), diff --git a/src/main/java/net/tfminecraft/games/wager/PlayerAccount.java b/src/main/java/net/tfminecraft/games/wager/PlayerAccount.java index 3c1a1c3..36b1805 100644 --- a/src/main/java/net/tfminecraft/games/wager/PlayerAccount.java +++ b/src/main/java/net/tfminecraft/games/wager/PlayerAccount.java @@ -22,6 +22,7 @@ public final class PlayerAccount implements MoneyAccount { private final int streetId; /** Set when the items are worth what they were wagered for rather than a coin value. */ private final int declaredUnit; + private List planningGroups; PlayerAccount(Player player, UUID id, Predicate allowed, Location dropAt, int streetId) { this(player, id, allowed, dropAt, streetId, 0); @@ -37,6 +38,29 @@ public final class PlayerAccount implements MoneyAccount { this.declaredUnit = declaredUnit; } + net.tfminecraft.tlibs.character.CharacterRef character() { + return player == null ? null : net.tfminecraft.tlibs.character.Characters.currentSession(player) + .map(session -> session.ref()).orElse(null); + } + + boolean online() { return player != null && player.isOnline(); } + + Object snapshot() { + java.util.List state = new java.util.ArrayList<>(); + state.add(character()); + state.add(player != null && player.isOnline()); + if (player != null) for (ItemStack item : player.getInventory().getContents()) + state.add(item == null ? null : item.clone()); + return state; + } + + @Override + public MoneyAccount planningCopy() { + PlayerAccount copy = new PlayerAccount(player, id, allowed, dropAt, streetId, declaredUnit); + copy.planningGroups = groups(); + return copy; + } + @Override public String label() { return "player"; @@ -209,6 +233,13 @@ private void drop(ItemStack items) { /** Coins of the same kind and value, wherever they sit, counted as one pool. */ private List groups() { List out = new ArrayList<>(); + if (planningGroups != null) { + for (Group group : planningGroups) { + if (group.count > 0) out.add(new Group(group.unit, group.one.clone(), group.count)); + } + out.sort((a, b) -> Integer.compare(b.unit, a.unit)); + return out; + } if (player == null || !player.isOnline()) { return out; } @@ -314,7 +345,8 @@ private boolean makeChange() { return false; } for (ItemStack made : change.into()) { - give(made.clone()); + if (planningGroups == null) give(made.clone()); + else merge(planningGroups, made, ChipItems.unitDenars(made)); } return true; } @@ -322,6 +354,15 @@ private boolean makeChange() { /** Pulls the counted items back out of wherever they are sitting now. */ private int strip(Group group, int want) { int left = want; + if (planningGroups != null) { + for (Group source : planningGroups) { + if (source.unit != group.unit || !source.one.isSimilar(group.one)) continue; + int take = Math.min(source.count, left); + source.count -= take; + left -= take; + } + return want - left; + } ItemStack[] contents = player.getInventory().getContents(); for (int slot = 0; slot < contents.length && left > 0; slot++) { ItemStack stack = contents[slot]; diff --git a/src/main/java/net/tfminecraft/games/wager/TaxSink.java b/src/main/java/net/tfminecraft/games/wager/TaxSink.java index 7ca05b4..f49fb12 100644 --- a/src/main/java/net/tfminecraft/games/wager/TaxSink.java +++ b/src/main/java/net/tfminecraft/games/wager/TaxSink.java @@ -1,52 +1,29 @@ package net.tfminecraft.games.wager; import java.util.List; - import org.bukkit.inventory.ItemStack; -/** - * Where withheld citizen tax chips go. They are destroyed rather than banked or left on the - * felt, so the same denars are not taxed twice as guild gambling profit. - */ +/** Withheld physical chips become a credit to the captured jurisdiction in MoneyTx's posting. */ final class TaxSink implements MoneyAccount { - - static final TaxSink INSTANCE = new TaxSink(); - - private TaxSink() {} - - @Override - public String label() { - return "citizen tax"; - } - - @Override - public int available() { - return 0; - } - - @Override - public Withdrawal planTake(int denars, ItemStack template) { - return null; - } - - @Override - public Withdrawal planTakeAll(Integer street) { - return null; - } - - @Override - public int largestTakeUpTo(int denars, ItemStack template) { - return 0; - } - - @Override - public boolean canAccept(int denars) { - return true; - } - - @Override - public int accept(List stakes) { - // Chips stop existing. Nothing is banked and the felt total does not change. - return TableLedger.valueOf(stakes); + private final CitizenTax.Levy levy; + private int authorized; + private int received; + TaxSink(CitizenTax.Levy levy) { this.levy = levy; } + CitizenTax.Levy levy() { return levy; } + int received() { return received; } + void authorize(int denars) { authorized = Math.addExact(authorized, denars); } + @Override public String ledgerAccount() { return levy.account(); } + @Override public String label() { return "citizen tax"; } + @Override public int available() { return 0; } + @Override public Withdrawal planTake(int denars, ItemStack template) { return null; } + @Override public Withdrawal planTakeAll(Integer street) { return null; } + @Override public int largestTakeUpTo(int denars, ItemStack template) { return 0; } + @Override public boolean canAccept(int denars) { return levy.account() != null; } + @Override public int accept(List stakes) { + int value = TableLedger.valueOf(stakes); + if (authorized < value) return 0; + authorized -= value; + received += value; + return value; } } diff --git a/src/main/java/net/tfminecraft/games/wager/WagerEngine.java b/src/main/java/net/tfminecraft/games/wager/WagerEngine.java index 883f97d..48cf1ac 100644 --- a/src/main/java/net/tfminecraft/games/wager/WagerEngine.java +++ b/src/main/java/net/tfminecraft/games/wager/WagerEngine.java @@ -128,7 +128,9 @@ public TxResult sweepPot(Table table, Player dest, UUID winner, List 0 && winner != null) { - moved += payPotCoins(table, dest, winner, coinTotal, flights, reason).moved(); + TxResult paid = payPotCoins(table, dest, winner, coinTotal, flights, reason); + if (!paid.ok()) return paid; + moved += paid.moved(); } List owners = potOwners(table); if (owners.isEmpty()) { @@ -140,6 +142,10 @@ public TxResult sweepPot(Table table, Player dest, UUID winner, List 0) { - CitizenTax.tell(dest, levy.tax()); - } - moved += taxMoved; + TaxSink tax = new TaxSink(levy); + MoneyTx tx = begin(table, reason).taxQuote(levy).animate(flights); + if (net > 0) tx.spread(payee, net, sources); + if (levy.chips() > 0) tx.spread(tax, levy.chips(), sources); + TxResult result = tx.commit(); + if (!result.ok()) { + WagerJournal.quarantine(table, "Unpaid pot outcome: owner=" + owner + ", gross=" + denars + ", profit=" + profit + ", quote=" + levy); + return result; } + int moved = result.moved(); + if (tax.received() > 0) CitizenTax.tell(dest, tax.received()); table.roundMoney().recordProfit(owner, Math.max(0, moved - returnedPrincipal)); + if (moved < denars) WagerJournal.quarantine(table, "Partial pot outcome: owner=" + owner + + ", gross=" + denars + ", moved=" + moved + ", remaining=" + (denars - moved) + + ", withheld=" + tax.received() + ", posting=" + tx.operationId() + ", quote=" + levy); return moved > 0 ? TxResult.done(moved, flights) : TxResult.nothing(); } + private CitizenTax.Levy quote(Table table, UUID owner, int profit) { + try { + return CitizenTax.quote(table.wagerCharacters().get(owner), profit); + } catch (RuntimeException | LinkageError ex) { + return null; + } + } + /** * Pay blackjack win profit to a player, withholding citizen tax to the sink first. Tray pays - * before whoever backs the table; tax chips are destroyed rather than banked. + * before whoever backs the table; withheld chips credit the captured tax jurisdiction. */ public PayWinResult payWin(Table table, Player winner, UUID owner, int profit, Player dealer, boolean dealerBacked, ItemStack template, List flights) { if (profit < 1) { return PayWinResult.NONE; } - CitizenTax.Levy levy = CitizenTax.levy(winner, profit); + CitizenTax.Levy levy = quote(table, owner, profit); + if (levy == null) { + WagerJournal.quarantine(table, "Unresolved win tax/payout: owner=" + owner + ", profit=" + profit); + return new PayWinResult(0, 0, profit); + } int net = profit - levy.chips(); MoneyAccount payee = Accounts.payee(table, winner, owner); List sources = winSources(table, dealer, dealerBacked); @@ -268,30 +287,22 @@ public PayWinResult payWin(Table table, Player winner, UUID owner, int profit, P } int trayBefore = tray(table); - int paidNet = 0; - if (net > 0) { - paidNet = begin(table, "win payout").animate(flights) - .spread(payee, net, sources, template) - .commit() - .moved(); + TaxSink tax = new TaxSink(levy); + MoneyTx tx = begin(table, "win payout").taxQuote(levy).animate(flights); + if (net > 0) tx.spread(payee, net, sources, template); + if (levy.chips() > 0) tx.spread(tax, levy.chips(), sources, template); + TxResult result = tx.commit(); + if (!result.ok()) { + WagerJournal.quarantine(table, "Unpaid win outcome: owner=" + owner + ", profit=" + profit + ", quote=" + levy); + return new PayWinResult(0, 0, profit); } + int moved = result.moved(); int trayMoved = trayBefore - tray(table); - - int paidTax = 0; - if (levy.chips() > 0) { - int trayBeforeTax = tray(table); - paidTax = begin(table, "citizen tax").animate(flights) - .spread(Accounts.taxSink(), levy.chips(), sources, template) - .commit() - .moved(); - if (paidTax > 0) { - CitizenTax.tell(winner, levy.tax()); - } - trayMoved += trayBeforeTax - tray(table); - } - - int moved = paidNet + paidTax; + if (tax.received() > 0) CitizenTax.tell(winner, tax.received()); table.roundMoney().recordProfit(owner, moved); + if (moved < profit) WagerJournal.quarantine(table, "Partial win outcome: owner=" + owner + + ", profit=" + profit + ", moved=" + moved + ", remaining=" + (profit - moved) + + ", withheld=" + tax.received() + ", posting=" + tx.operationId() + ", quote=" + levy); return new PayWinResult(moved, trayMoved, Math.max(0, profit - moved)); } diff --git a/src/main/java/net/tfminecraft/games/wager/WagerJournal.java b/src/main/java/net/tfminecraft/games/wager/WagerJournal.java new file mode 100644 index 0000000..fee2f47 --- /dev/null +++ b/src/main/java/net/tfminecraft/games/wager/WagerJournal.java @@ -0,0 +1,116 @@ +package net.tfminecraft.games.wager; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Supplier; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.games.Games; +import net.tfminecraft.games.persistence.DurableFiles; +import net.tfminecraft.games.table.Table; +import net.tfminecraft.games.table.TableManager; + +/** A durable boundary between ledger postings and item/table side effects. */ +public final class WagerJournal { + record Movement(String from, String to, int value, List items, CitizenTax.Levy tax) {} + record Plan(String reason, List legs, List movements, int floatBefore, int floatAfter, int profit, + Map characters, CitizenTax.Levy taxQuote) {} + private record Intent(String operationId, Plan plan, String state) {} + private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Map ACTIVE = new HashMap<>(); + private WagerJournal() {} + + /** Previous-process intents always require review, including plugin re-enables. */ + public static void initialize() { ACTIVE.clear(); } + + private static Path file(Table table) { + return Games.plugin.getDataFolder().toPath().resolve("Data/wager-intents").resolve(table.getId() + ".json"); + } + + public static boolean blocked(Table table) { + return Games.plugin != null && Games.plugin.getDataFolder() != null && Files.exists(file(table)); + } + + public static void quarantine(Table table, String reason) { + table.setMoneyBlocked(true); + try { + Path file = file(table); + com.google.gson.JsonObject review = Files.exists(file) + ? com.google.gson.JsonParser.parseString(Files.readString(file)).getAsJsonObject() + : new com.google.gson.JsonObject(); + if (!review.has("operationId")) review.addProperty("operationId", "games:" + table.getId() + ":" + UUID.randomUUID()); + if (!review.has("state")) review.addProperty("state", "review"); + var observations = review.has("unpaidOutcomes") ? review.getAsJsonArray("unpaidOutcomes") : new com.google.gson.JsonArray(); + observations.add(JSON.toJsonTree(Map.of("reason", reason, "characters", table.wagerCharacters()))); + review.add("unpaidOutcomes", observations); + DurableFiles.write(file, JSON.toJson(review)); + TableManager.get().saveWager(table); + } catch (IOException | RuntimeException ex) { + table.setMoneyBlocked(true); + warn(table, ex); + } + } + + static TxResult apply(Table table, Plan plan, java.util.function.Consumer operation, Supplier mutation) { + Path file = file(table); + Intent intent = ACTIVE.get(file); + if (intent == null && Files.exists(file)) return refused(); + if (intent != null && (!"prepared".equals(intent.state()) || !intent.plan().equals(plan))) return refused(); + try { + if (intent == null) { + intent = new Intent("games:" + table.getId() + ":" + UUID.randomUUID(), plan, "prepared"); + DurableFiles.write(file, JSON.toJson(intent)); + ACTIVE.put(file, intent); + } + operation.accept(intent.operationId()); + Result result = Denars.status(intent.operationId()).orElse(null); + if (result == null) result = Denars.post(intent.operationId(), "games:" + plan.reason(), plan.legs()); + if (result == Result.REFUSED) { + DurableFiles.delete(file); + ACTIVE.remove(file); + return refused(); + } + if (result == Result.UNAVAILABLE) return refused(); + if (result != Result.APPLIED) { + Intent conflict = new Intent(intent.operationId(), plan, "conflict"); + ACTIVE.put(file, conflict); + DurableFiles.write(file, JSON.toJson(conflict)); + return refused(); + } + Intent applying = new Intent(intent.operationId(), plan, "applying"); + ACTIVE.put(file, applying); + DurableFiles.write(file, JSON.toJson(applying)); + TxResult moved = mutation.get(); + if (!moved.ok() || table.houseFloat() != plan.floatAfter()) throw new IllegalStateException("Item/float application differed from intent"); + TableManager.get().saveWager(table); + if (!GuildBank.declareProfit(table, intent.operationId(), plan.profit())) + throw new IllegalStateException("Casino profit bookkeeping is pending"); + DurableFiles.delete(file); + ACTIVE.remove(file); + return moved; + } catch (IOException | RuntimeException ex) { + table.setMoneyBlocked(true); + warn(table, ex); + return refused(); + } + } + + static boolean retryable(Table table) { + if (Games.plugin == null || Games.plugin.getDataFolder() == null) return false; + Intent intent = ACTIVE.get(file(table)); + return intent != null && "prepared".equals(intent.state()); + } + + private static TxResult refused() { return TxResult.failed(TxResult.Reason.BANK_SHORT, 0, 0); } + private static void warn(Table table, Exception ex) { + if (Games.plugin != null) Games.plugin.getLogger().severe("[Games] Table " + table.getId() + " money requires review: " + ex); + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 1b6eeea..c44fd2b 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,8 +3,8 @@ api-version: '1.21.10' name: Games version: ${project.version} author: Drefvelin -depend: [TLibs, ItemsAdder, ProtocolLib] -softdepend: [DenarEconomy, RPCharacters, SimpleFactions] +depend: [TLibs, ItemsAdder, ProtocolLib, DenarEconomy] +softdepend: [RPCharacters, SimpleFactions] commands: games: diff --git a/src/test/java/net/tfminecraft/games/guild/GuildTableMigrationTest.java b/src/test/java/net/tfminecraft/games/guild/GuildTableMigrationTest.java new file mode 100644 index 0000000..a1b7bcf --- /dev/null +++ b/src/test/java/net/tfminecraft/games/guild/GuildTableMigrationTest.java @@ -0,0 +1,81 @@ +package net.tfminecraft.games.guild; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class GuildTableMigrationTest { + @TempDir Path data; + private Path source() throws Exception { + Path source = data.resolve("table.json"); + Files.writeString(source, "{\"ownerGuildId\":\"guild\",\"houseFloat\":17}"); + return source; + } + @Test void waitsWithoutBackupOrMutationUntilFactionMigrationIsReady() throws Exception { + Path table = source(); + String before = Files.readString(table); + try (var guilds = mockStatic(GuildTables.class)) { + assertFalse(GuildTableMigration.prepare(table, data.resolve("migration"))); + assertEquals(before, Files.readString(table)); + assertFalse(Files.exists(data.resolve("migration"))); + guilds.verify(() -> GuildTables.incarnation(anyString()), never()); + } + } + @Test void recordsImmutableBackupAndNeverAdoptsAReplacementGuild() throws Exception { + Path table = source(); + String before = Files.readString(table); + UUID incarnation = UUID.randomUUID(); + try (var guilds = mockStatic(GuildTables.class)) { + guilds.when(GuildTables::ready).thenReturn(true); + guilds.when(() -> GuildTables.incarnation("guild")).thenReturn(incarnation); + assertTrue(GuildTableMigration.prepare(table, data.resolve("migration"))); + String migrated = Files.readString(table); + assertEquals(before, Files.readString(data.resolve("migration/backup/table.json"))); + assertTrue(migrated.contains(incarnation.toString())); + assertEquals(17, JsonParser.parseString(migrated).getAsJsonObject().get("houseFloat").getAsInt()); + guilds.when(() -> GuildTables.incarnation("guild")).thenReturn(UUID.randomUUID()); + assertTrue(GuildTableMigration.prepare(table, data.resolve("migration"))); + assertEquals(migrated, Files.readString(table)); + guilds.verify(() -> GuildTables.incarnation("guild"), times(1)); + } + } + @Test void interruptedPlanReplaysOriginalIncarnationAndNeverResolvesAgain() throws Exception { + Path table = source(); + String before = Files.readString(table); + UUID incarnation = UUID.randomUUID(); + try (var guilds = mockStatic(GuildTables.class)) { + guilds.when(GuildTables::ready).thenReturn(true); + guilds.when(() -> GuildTables.incarnation("guild")).thenReturn(incarnation); + assertTrue(GuildTableMigration.prepare(table, data.resolve("migration"))); + String migrated = Files.readString(table); + Files.writeString(table, before); + guilds.when(() -> GuildTables.incarnation("guild")).thenReturn(UUID.randomUUID()); + assertTrue(GuildTableMigration.prepare(table, data.resolve("migration"))); + assertEquals(migrated, Files.readString(table)); + guilds.verify(() -> GuildTables.incarnation("guild"), times(1)); + } + } + @Test void missingGuildIsPermanentlyUnresolvedAndPersonalTablesNeedNoMigration() throws Exception { + Path table = source(); + try (var guilds = mockStatic(GuildTables.class)) { + guilds.when(GuildTables::ready).thenReturn(true); + assertTrue(GuildTableMigration.prepare(table, data.resolve("migration"))); + var migrated = JsonParser.parseString(Files.readString(table)).getAsJsonObject(); + assertTrue(migrated.get("ownerGuildMigrated").getAsBoolean()); + assertFalse(migrated.has("ownerGuildIncarnation")); + guilds.when(() -> GuildTables.incarnation("guild")).thenReturn(UUID.randomUUID()); + assertTrue(GuildTableMigration.prepare(table, data.resolve("migration"))); + assertEquals(migrated, JsonParser.parseString(Files.readString(table))); + guilds.verify(() -> GuildTables.incarnation("guild"), times(1)); + Files.writeString(table, "{}"); + guilds.when(GuildTables::ready).thenReturn(false); + assertTrue(GuildTableMigration.prepare(table, data.resolve("unused"))); + assertFalse(Files.exists(data.resolve("unused"))); + } + } +} diff --git a/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java b/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java index de3b92e..39e6b23 100644 --- a/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java +++ b/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java @@ -21,6 +21,7 @@ import net.tfminecraft.games.table.TableHouse; import net.tfminecraft.games.table.TableManager; import net.tfminecraft.simplefactions.enums.GuildModifier; +import net.tfminecraft.simplefactions.api.SimpleFactionsApi; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.managers.FactionManager; @@ -29,6 +30,8 @@ class GuildTablesTest { private Games previousPlugin; private Logger logger; private MockedStatic factions; + private MockedStatic api; + private static final UUID INCARNATION = UUID.fromString("00000000-0000-0000-0000-000000000111"); private MockedStatic managers; private PluginManager plugins; private Plugin integration; @@ -52,16 +55,19 @@ void setUp() throws ReflectiveOperationException { when(plugins.getPlugin("SimpleFactions")).thenReturn(integration); bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + api = mockStatic(SimpleFactionsApi.class); + api.when(SimpleFactionsApi::ready).thenReturn(true); factions = mockStatic(FactionManager.class); guild = mock(Guild.class); when(guild.getId()).thenReturn("guild"); + when(guild.getIncarnationId()).thenReturn(INCARNATION); when(guild.getName()).thenReturn("The Guild"); - when(guild.getLeader()).thenReturn("ALICE"); + when(guild.isLeader(any(Player.class))).thenReturn(true); when(guild.getModifier(GuildModifier.AUTO_DEALER_TABLES)).thenReturn(2.0); player = mock(Player.class); when(player.getName()).thenReturn("Alice"); when(player.getUniqueId()).thenReturn(UUID.randomUUID()); - factions.when(() -> FactionManager.getGuildByMember("Alice")).thenReturn(guild); + factions.when(() -> FactionManager.getGuildByMember(player)).thenReturn(guild); factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(guild); TableManager manager = mock(TableManager.class); when(manager.tables()).thenReturn(tables); @@ -72,6 +78,7 @@ void setUp() throws ReflectiveOperationException { @AfterEach void closeMocks() { if (managers != null) managers.close(); + if (api != null) api.close(); if (factions != null) factions.close(); if (bukkit != null) bukkit.close(); Games.plugin = previousPlugin; @@ -160,9 +167,9 @@ void placementStampsMembershipAndRequiresLeaderAndFreeSlot() { TableHouse house = new TableHouse(); assertNull(GuildTables.refuseKey(player, house, null)); assertEquals("guild", house.ownerGuildId()); - when(guild.getLeader()).thenReturn("Bob"); + when(guild.isLeader(player)).thenReturn(false); assertEquals("place.not_leader", GuildTables.refuseKey(player, house, null)); - when(guild.getLeader()).thenReturn("alice"); + when(guild.isLeader(player)).thenReturn(true); tables.addAll(List.of(table("guild"), table("guild"))); assertEquals("place.no_slots", GuildTables.refuseKey(player, house, null)); assertNull(GuildTables.refuseKey(player, house, tables.getFirst()), "editing a counted table uses its existing slot"); @@ -182,14 +189,14 @@ void deletedGuildOrMissingLeaderCannotAuthorisePlacement() { TableHouse house = new TableHouse(); house.setOwnerGuildId("deleted"); assertEquals("place.not_leader", GuildTables.refuseKey(player, house, null)); - when(guild.getLeader()).thenReturn(null); + when(guild.isLeader(player)).thenReturn(false); house.setOwnerGuildId("guild"); assertEquals("place.not_leader", GuildTables.refuseKey(player, house, null)); } @Test void guildlessPlacementIsRefusedButStaffMintNeedsNoGuildSlot() { - factions.when(() -> FactionManager.getGuildByMember("Alice")).thenReturn(null); + factions.when(() -> FactionManager.getGuildByMember(player)).thenReturn(null); assertEquals("place.no_guild", GuildTables.refuseKey(player, new TableHouse(), null)); TableHouse staff = new TableHouse(); staff.setStaffMint(true); @@ -235,10 +242,10 @@ void dealingRequiresMembershipPersonalOwnershipOrExplicitStaffPermission() { @Test void onlyTheLeaderOfTheOwningGuildLeadsItsTable() { - assertTrue(GuildTables.leadsOwner(table("guild"), player), "the leader is matched ignoring case"); - when(guild.getLeader()).thenReturn("Bob"); + assertTrue(GuildTables.leadsOwner(table("guild"), player), "the active character leads the captured guild incarnation"); + when(guild.isLeader(player)).thenReturn(false); assertFalse(GuildTables.leadsOwner(table("guild"), player), "a member who does not lead it"); - when(guild.getLeader()).thenReturn("Alice"); + when(guild.isLeader(player)).thenReturn(true); assertFalse(GuildTables.leadsOwner(table(null), player), "a personal table has no guild to lead"); assertFalse(GuildTables.leadsOwner(table(" "), player)); assertFalse(GuildTables.leadsOwner(table("deleted"), player)); @@ -256,7 +263,7 @@ void blankSavedGuildFallsBackToPersonalOwnershipAndDeletedGuildHasNoMembers() { void incompatibleIntegrationCannotAuthorizePlacementOrDealing() { factions.when(() -> FactionManager.getGuildByString("guild")) .thenThrow(new NoClassDefFoundError("guild API changed")); - factions.when(() -> FactionManager.getGuildByMember("Alice")) + factions.when(() -> FactionManager.getGuildByMember(player)) .thenThrow(new IllegalStateException("integration unavailable")); assertNull(GuildTables.guildId(player)); assertNull(GuildTables.displayName("guild")); @@ -285,6 +292,39 @@ void refusalMessagesIncludeUsedSlotsAndCapOnlyWhenRelevant() { private static Table table(String guild) { Table table = new Table(UUID.randomUUID(), "blackjack", null, 0, null); table.setOwnerGuildId(guild); + table.setOwnerGuildIncarnation(INCARNATION); return table; } + + @Test void installedFactionWaitingDisabledAndLookupFailuresFailClosed() { + Table table = table("guild"); + api.when(SimpleFactionsApi::ready).thenReturn(false); + assertFalse(GuildTables.ready()); + assertNull(GuildTables.guildId(player)); + assertTrue(GuildTables.frozen(table)); + api.when(SimpleFactionsApi::ready).thenReturn(true); + assertTrue(GuildTables.ready()); + assertFalse(GuildTables.frozen(table)); + when(integration.isEnabled()).thenReturn(false); + assertTrue(GuildTables.frozen(table)); + when(integration.isEnabled()).thenReturn(true); + api.when(SimpleFactionsApi::ready).thenThrow(new IllegalStateException("waiting")); + assertFalse(GuildTables.ready()); + assertTrue(GuildTables.frozen(table)); + } + + @Test void replacementGuildCannotUseOrCountAnOldIncarnationAndAccountNamesGiveNoRights() { + Table table = table("guild"); + tables.add(table); + when(guild.getIncarnationId()).thenReturn(UUID.randomUUID()); + assertEquals(0, GuildTables.count("guild")); + assertTrue(GuildTables.frozen(table)); + assertFalse(GuildTables.leadsOwner(table, player)); + when(guild.getIncarnationId()).thenReturn(INCARNATION); + when(guild.isLeader(player)).thenReturn(false); + when(guild.getLeader()).thenReturn("Alice"); + assertFalse(GuildTables.leadsOwner(table, player)); + when(guild.isLeader(player)).thenReturn(true); + assertTrue(GuildTables.leadsOwner(table, player)); + } } diff --git a/src/test/java/net/tfminecraft/games/support/TestCharacters.java b/src/test/java/net/tfminecraft/games/support/TestCharacters.java new file mode 100644 index 0000000..80db940 --- /dev/null +++ b/src/test/java/net/tfminecraft/games/support/TestCharacters.java @@ -0,0 +1,30 @@ +package net.tfminecraft.games.support; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.mockito.MockedStatic; +import static org.mockito.Mockito.*; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; + +/** Deterministic character service boundary for gameplay fixtures. */ +public final class TestCharacters { + private TestCharacters() {} + public static CharacterRef ref(Player player) { + UUID owner = player.getUniqueId(); + return new CharacterRef(owner, UUID.nameUUIDFromBytes(("character:" + owner).getBytes(StandardCharsets.UTF_8))); + } + public static MockedStatic mockSessions() { + MockedStatic scope = mockStatic(Characters.class); + scope.when(() -> Characters.currentSession(any())).thenAnswer(call -> { + Player player = call.getArgument(0); + return player == null ? Optional.empty() : Optional.of(new CharacterSession(ref(player), 1L)); + }); + scope.when(() -> Characters.displayName(any())).thenAnswer(call -> ((Player) call.getArgument(0)).getName()); + scope.when(() -> Characters.isCurrent(any())).thenReturn(true); + return scope; + } +} diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerActionTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerActionTest.java index a6e431d..319509c 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerActionTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerActionTest.java @@ -431,12 +431,13 @@ void selectingGameWithoutSurfaceArmsTheNextPlacement(boolean requireDeck) { when(plugins.getPlugin("SimpleFactions")).thenReturn(integration); Guild guild = mock(Guild.class); when(guild.getId()).thenReturn("card-guild"); - when(guild.getLeader()).thenReturn(player.getName()); + when(guild.isLeader(player)).thenReturn(true); + when(guild.getIncarnationId()).thenReturn(UUID.randomUUID()); when(guild.getModifier(GuildModifier.AUTO_DEALER_TABLES)).thenReturn(1.0); try (var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); var factions = mockStatic(FactionManager.class)) { bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); - factions.when(() -> FactionManager.getGuildByMember(player.getName())).thenReturn(guild); + factions.when(() -> FactionManager.getGuildByMember(player)).thenReturn(guild); factions.when(() -> FactionManager.getGuildByString("card-guild")).thenReturn(guild); player.getInventory().setItemInMainHand(new ItemStack(Material.PAPER, 2)); manager.selectGame(player, "blackjack", player.getLocation(), true); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java index 5113e05..c1ae10f 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java @@ -156,7 +156,7 @@ void taxedAutoDealerWinBreaksLargeCoinsAndPaysProfitPlusReturnedStake(int firstR && item.getType() == Material.GOLD_BLOCK))) .thenReturn(List.of(new ItemStack(Material.GOLD_NUGGET, 100))); int profit = firstRank == 1 ? 150 : 100; - tax.when(() -> CitizenTax.levy(player, profit)).thenReturn(new CitizenTax.Levy(10, 10)); + tax.when(() -> CitizenTax.quote(net.tfminecraft.games.support.TestCharacters.ref(player), profit)).thenReturn(new CitizenTax.Levy(10, 10)); player.getInventory().setItemInMainHand(pouch); clickFeltAt(player, 0); assertEquals(100, manager.ownedDenars(table, player.getUniqueId())); @@ -465,7 +465,7 @@ void aBoxThatWalksAwayBeforeTheDealTakesItsStakeBack() throws Exception { } @Test - void aDealerWithNoFloatWhoLogsOffMidRoundLeavesTheTableFreeToDeal() throws Exception { + void aDealerWithNoFloatWhoLogsOffMidRoundLeavesARecordedWinningObligation() throws Exception { restoreShoe(fullDeck(), List.of("oseni_10", "clubs_10", "oseni_9", "clubs_8")); openBets(); player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 4)); @@ -474,12 +474,18 @@ void aDealerWithNoFloatWhoLogsOffMidRoundLeavesTheTableFreeToDeal() throws Excep manager.onQuit(new PlayerQuitEvent(dealer, "quit")); dealer.disconnect(); manager.applyPlayCall(player, "stand"); - advanceUntil(() -> !table.live()); - assertEquals(4, gold(player), "nineteen beats eighteen, but the dealer left nothing to pay the win with"); - assertNull(table.floatOwner(), "an empty tray holds nothing for anyone"); + advanceUntil(() -> BlackjackGame.SETTLE.equals(table.phase())); + assertTrue(table.moneyBlocked()); + assertTrue(table.live()); + assertEquals(2, gold(player), "Original stake remains on the frozen table beside the unpaid win"); + assertEquals(2, manager.ownedDenars(table, player.getUniqueId())); + var review = com.google.gson.JsonParser.parseString(Files.readString(data.resolve( + "Data/wager-intents/" + table.getId() + ".json"))).getAsJsonObject(); + assertTrue(review.getAsJsonArray("unpaidOutcomes").get(0).getAsJsonObject().get("reason").getAsString().contains("remaining=2")); + assertEquals(dealer.getUniqueId(), table.floatOwner(), "Frozen settlement retains the departed dealer identity for review"); player.addAttachment(Games.plugin, "games.admin", true); clickShoe(player); - assertEquals(player.getUniqueId(), table.dealerId()); + assertNull(table.dealerId(), "A frozen obligation cannot be assigned to another dealer"); } /** diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerChipLayoutTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerChipLayoutTest.java index 7b9dbae..b301df3 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerChipLayoutTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerChipLayoutTest.java @@ -196,6 +196,7 @@ void placedHeapOverflowsBesideItsSpotWithCappedLayersAndConfiguredVerticalSpacin @Test void hiddenChipsKeepAmountLabelsCurrentThroughRefundsAndRestoreWithoutLosingMoney() { Table table = place(false); + table.wagerCharacters().put(player.getUniqueId(), net.tfminecraft.games.support.TestCharacters.ref(player)); restore(table, player.getUniqueId(), Material.GOLD_NUGGET, 1, 4, -0.75, 0.4); restore(table, table.getId(), Material.GOLD_NUGGET, 1, 3, null, null); manager.syncAllChips(table); @@ -313,6 +314,7 @@ void fractionalExternalCoinsAreRefusedWithoutConsumingItemsWithOrWithoutSilverDe void trayWithoutConfiguredSlotUsesTableOriginAndStillPaysItsRealBalance() { Cache.tableLayouts.remove("freeplay"); Table table = place(false); + table.wagerCharacters().put(player.getUniqueId(), net.tfminecraft.games.support.TestCharacters.ref(player)); restore(table, table.getId(), Material.GOLD_NUGGET, 1, 2, null, null); manager.syncBucketChips(table, table.getId()); PotPile pile = table.getPiles().getFirst(); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerFixture.java b/src/test/java/net/tfminecraft/games/table/TableManagerFixture.java index 4e2dd12..b6f5d84 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerFixture.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerFixture.java @@ -62,6 +62,7 @@ abstract class TableManagerFixture { DisplayManager display; Game game; ItemAPI items; + MockedStatic denars; MockedStatic libs; MockedStatic cards; MockedStatic displays; @@ -123,6 +124,15 @@ abstract class TableManagerFixture { MockBukkit.getMock().addWorld(world); player = MockBukkit.getMock().addPlayer(); player.teleport(new Location(world, 0, 65, 0, 90, 0)); + track(net.tfminecraft.games.support.TestCharacters.mockSessions()); + denars = track(mockStatic(net.tfminecraft.denareconomy.api.Denars.class)); + denars.when(net.tfminecraft.denareconomy.api.Denars::taxReady).thenReturn(true); + denars.when(net.tfminecraft.denareconomy.api.Denars::ready).thenReturn(true); + denars.when(() -> net.tfminecraft.denareconomy.api.Denars.guild(any())).thenCallRealMethod(); + denars.when(() -> net.tfminecraft.denareconomy.api.Denars.post(anyString(), anyString(), anyList())) + .thenReturn(net.tfminecraft.denareconomy.api.Result.APPLIED); + var factionsReady = track(mockStatic(net.tfminecraft.simplefactions.api.SimpleFactionsApi.class)); + factionsReady.when(net.tfminecraft.simplefactions.api.SimpleFactionsApi::ready).thenReturn(true); libs = track(mockStatic(TLibs.class)); items = mock(ItemAPI.class, RETURNS_DEEP_STUBS); libs.when(TLibs::getItemAPI).thenReturn(items); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerInteractionTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerInteractionTest.java index 02c273f..d9bb50d 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerInteractionTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerInteractionTest.java @@ -142,7 +142,9 @@ class TableManagerInteractionTest extends TableManagerFixture { when(factions.isEnabled()).thenReturn(true); when(plugins.getPlugin("SimpleFactions")).thenReturn(factions); Guild guild = mock(Guild.class); - when(guild.getLeader()).thenReturn(leader.getName()); + when(guild.isLeader(leader)).thenReturn(true); + table.setOwnerGuildIncarnation(UUID.randomUUID()); + when(guild.getIncarnationId()).thenReturn(table.ownerGuildIncarnation()); try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); MockedStatic registry = mockStatic(FactionManager.class)) { bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerLifecycleTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerLifecycleTest.java index c5ce830..79641ba 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerLifecycleTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerLifecycleTest.java @@ -326,6 +326,9 @@ private ChunkLoadEvent chunkLoad(World where, int x, int z) { private JsonObject document(UUID id, String gameId) { JsonObject document = new JsonObject(); + JsonObject characters = new JsonObject(); + characters.addProperty(player.getUniqueId().toString(), net.tfminecraft.games.support.TestCharacters.ref(player).characterId().toString()); + document.add("wagerCharacters", characters); document.addProperty("id", id.toString()); document.addProperty("gameId", gameId); document.addProperty("world", world.getName()); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java index cda3d61..4dd5b3c 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java @@ -147,6 +147,8 @@ void idleSavedTableRestoresDeckOrderHouseSettingsAndOwnership() throws Exception saved.add("discarded", json.toJsonTree(List.of("one"))); saved.addProperty("ownerPlayer", player.getUniqueId().toString()); saved.addProperty("ownerGuildId", "guild"); + saved.addProperty("ownerGuildIncarnation", UUID.randomUUID().toString()); + saved.addProperty("ownerGuildMigrated", true); saved.addProperty("autoDealer", true); saved.addProperty("staffMint", false); saved.addProperty("houseFloat", 17); @@ -503,6 +505,8 @@ void orphanedPlayerItemsNeverBecomeGuildIncomeOrStaffMintMoney(boolean staffMint Bank bank = mock(Bank.class); Ledger ledger = mock(Ledger.class); when(guild.getId()).thenReturn("recovery-guild"); + UUID incarnation = UUID.randomUUID(); + when(guild.getIncarnationId()).thenReturn(incarnation); when(guild.getBank()).thenReturn(bank); when(guild.getLedger()).thenReturn(ledger); Faction faction = mock(Faction.class, RETURNS_DEEP_STUBS); @@ -523,8 +527,9 @@ void orphanedPlayerItemsNeverBecomeGuildIncomeOrStaffMintMoney(boolean staffMint if (staffMint) { verifyNoInteractions(bank, ledger); } else { - verify(bank).deposit(5.0); - verifyNoMoreInteractions(bank); + verifyNoInteractions(bank); + denars.verify(() -> net.tfminecraft.denareconomy.api.Denars.post(anyString(), anyString(), argThat(legs -> + legs.stream().anyMatch(leg -> leg.account().equals("guild:" + incarnation) && leg.cents() == 500)))); verifyNoInteractions(ledger); assertEquals(0, manager.table(id).houseFloat()); } @@ -564,6 +569,7 @@ void anAbsentDealersTraySurvivesARestartAndGoesBackWhenTheyLogIn() throws Except JsonObject saved = document(id); saved.getAsJsonArray("ledger").add(stake(id, 4)); saved.addProperty("floatOwner", gone.getUniqueId().toString()); + saved.getAsJsonObject("wagerCharacters").addProperty(gone.getUniqueId().toString(), net.tfminecraft.games.support.TestCharacters.ref(gone).characterId().toString()); write(id + ".json", saved); manager.loadAll(); Table loaded = manager.table(id); @@ -615,8 +621,40 @@ void placingATableRecordsWhetherItUsedADeck(boolean requireDeck) throws Exceptio assertEquals(requireDeck, read(table.getId()).get("deckConsumed").getAsBoolean()); } + @Test void legacyStakeWithoutCapturedCharacterRetainsItemsAndBlocksResetAcrossRestart() throws Exception { + UUID id = UUID.randomUUID(); + JsonObject saved = document(id); + saved.remove("wagerCharacters"); + saved.getAsJsonArray("ledger").add(stake(player.getUniqueId(), 3)); + write(id + ".json", saved); + manager.loadAll(); + Table frozen = manager.table(id); + assertTrue(frozen.moneyBlocked()); + assertEquals(3, frozen.ledger().total()); + assertEquals(0, inventoryGold()); + assertTrue(Files.exists(data.resolve("Data/wager-intents/" + id + ".json"))); + manager.loadAll(); + assertTrue(manager.table(id).moneyBlocked()); + assertEquals(3, manager.table(id).ledger().total()); + assertEquals(0, inventoryGold()); + } + + @Test void deferredLoadingDoesNotReplaceAnActiveTableStoredUnderAnImportedFilename() throws Exception { + UUID id = UUID.randomUUID(); + write("imported.json", document(id)); + manager.loadAll(); + Table active = manager.table(id); + active.startSession(); + manager.loadDeferred(); + assertSame(active, manager.table(id)); + assertTrue(active.live()); + } + private JsonObject document(UUID id) { JsonObject document = new JsonObject(); + JsonObject characters = new JsonObject(); + characters.addProperty(player.getUniqueId().toString(), net.tfminecraft.games.support.TestCharacters.ref(player).characterId().toString()); + document.add("wagerCharacters", characters); document.addProperty("id", id.toString()); document.addProperty("gameId", "freeplay"); document.addProperty("world", world.getName()); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerSettleTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerSettleTest.java index 5df83e3..fce2225 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerSettleTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerSettleTest.java @@ -65,7 +65,7 @@ private void pickUp(Table table, Player by) { manager.onHitEntity(hit); } - @Test void aGuildBankThatFailsMidDepositGetsTheTrayCoinsDroppedAtTheTableInstead() { + @Test void aRefusedGuildPostingRetainsTrayFloatAndTableForReview() { Table table = place(false); table.setOwnerGuildId("guild"); table.setHouseFloat(5); @@ -76,17 +76,25 @@ private void pickUp(Table table, Player by) { when(factions.isEnabled()).thenReturn(true); when(plugins.getPlugin("SimpleFactions")).thenReturn(factions); Guild guild = mock(Guild.class); + table.setOwnerGuildIncarnation(java.util.UUID.randomUUID()); + when(guild.getIncarnationId()).thenReturn(table.ownerGuildIncarnation()); Bank bank = mock(Bank.class); when(guild.getBank()).thenReturn(bank); - doThrow(new IllegalStateException("bank storage offline")).when(bank).deposit(anyDouble()); + denars.when(() -> net.tfminecraft.denareconomy.api.Denars.post(anyString(), anyString(), anyList())) + .thenReturn(net.tfminecraft.denareconomy.api.Result.REFUSED); try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); MockedStatic registry = mockStatic(FactionManager.class)) { bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); registry.when(() -> FactionManager.getGuildByString("guild")).thenReturn(guild); manager.bankAutoTray(table); } - assertEquals(5, dropped(), "the coins survive a bank that took nothing"); - assertTrue(table.ledger().isEmpty()); + assertEquals(0, dropped(), "Ledger refusal must not cause item delivery through a fallback"); + assertEquals(5, table.ledger().total()); + assertTrue(table.moneyBlocked()); + player.addAttachment(net.tfminecraft.games.Games.plugin, "games.admin", true); + pickUp(table, player); + assertSame(table, manager.table(table.getId())); + verifyNoInteractions(bank); assertEquals(5, table.houseFloat(), "nothing reached the bank, so the float is still owed"); } @@ -121,6 +129,8 @@ void aGuildTablesDealerWhoLeavesNeverTakesTheGuildsFloatWithThem(boolean midRoun when(factions.isEnabled()).thenReturn(true); when(plugins.getPlugin("SimpleFactions")).thenReturn(factions); Guild guild = mock(Guild.class); + table.setOwnerGuildIncarnation(java.util.UUID.randomUUID()); + when(guild.getIncarnationId()).thenReturn(table.ownerGuildIncarnation()); when(guild.getModifier(GuildModifier.AUTO_DEALER_TABLES)).thenReturn(2.0); try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); MockedStatic registry = mockStatic(FactionManager.class)) { diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerStoredDataTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerStoredDataTest.java index 36601e9..2115ee2 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerStoredDataTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerStoredDataTest.java @@ -41,6 +41,9 @@ private Path folder() { private JsonObject document(UUID id) { JsonObject document = new JsonObject(); + JsonObject characters = new JsonObject(); + characters.addProperty(player.getUniqueId().toString(), net.tfminecraft.games.support.TestCharacters.ref(player).characterId().toString()); + document.add("wagerCharacters", characters); document.addProperty("id", id.toString()); document.addProperty("gameId", "freeplay"); document.addProperty("world", world.getName()); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerWagerTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerWagerTest.java index e12d020..78ba150 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerWagerTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerWagerTest.java @@ -187,24 +187,29 @@ private TableLayout trayLayout() { when(plugin.isEnabled()).thenReturn(true); when(plugins.getPlugin("SimpleFactions")).thenReturn(plugin); Guild guild = mock(Guild.class); + table.setOwnerGuildIncarnation(UUID.randomUUID()); + when(guild.getIncarnationId()).thenReturn(table.ownerGuildIncarnation()); Bank bank = mock(Bank.class); Ledger ledger = mock(Ledger.class); when(guild.getBank()).thenReturn(bank); when(guild.getLedger()).thenReturn(ledger); + when(ledger.addCasinoProfitEntry(anyString(), eq(3.0))).thenReturn(true); try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); MockedStatic factions = mockStatic(FactionManager.class)) { bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(guild); manager.bankAutoTray(table); } - verify(bank).deposit(8.0); - verify(ledger).addCasinoProfitEntry(3.0); + verifyNoInteractions(bank); + denars.verify(() -> net.tfminecraft.denareconomy.api.Denars.post(anyString(), anyString(), argThat(legs -> + legs.stream().anyMatch(leg -> leg.account().equals("guild:" + table.ownerGuildIncarnation()) && leg.cents() == 800)))); + verify(ledger).addCasinoProfitEntry(startsWith("games:" + table.getId()), eq(3.0)); assertEquals(0, table.houseFloat()); assertTrue(table.ledger().isEmpty()); assertTrue(table.getPiles().isEmpty()); } - @Test void missingGuildBankDropsTrayCoinsAtTableInsteadOfDeletingThem() { + @Test void missingGuildIncarnationRetainsTrayForReview() { Table table = place(false); table.setOwnerGuildId("deleted-guild"); table.setHouseFloat(5); @@ -213,8 +218,10 @@ private TableLayout trayLayout() { int dropped = world.getEntitiesByClass(Item.class).stream() .filter(item -> item.getItemStack().getType() == Material.GOLD_NUGGET) .mapToInt(item -> item.getItemStack().getAmount()).sum(); - assertEquals(5, dropped); - assertTrue(table.ledger().isEmpty()); + assertEquals(0, dropped); + assertEquals(5, table.ledger().total()); + assertTrue(table.moneyBlocked()); + assertTrue(net.tfminecraft.games.wager.WagerJournal.blocked(table)); assertEquals(5, table.houseFloat(), "A refused bank transfer cannot retire its float"); } @@ -316,4 +323,15 @@ private TableLayout trayLayout() { assertSame(table, manager.table(table.getId())); verify(game).onTableRemoved(table); } + + @Test void newRoundWaitsForTaxReadinessWithoutCreatingAnIntent() { + Table table = place(false); + denars.when(net.tfminecraft.denareconomy.api.Denars::taxReady).thenReturn(false); + manager.beginSession(table); + assertFalse(table.live()); + assertFalse(net.tfminecraft.games.wager.WagerJournal.blocked(table)); + denars.when(net.tfminecraft.denareconomy.api.Denars::taxReady).thenReturn(true); + manager.beginSession(table); + assertTrue(table.live()); + } } diff --git a/src/test/java/net/tfminecraft/games/wager/AccountsTest.java b/src/test/java/net/tfminecraft/games/wager/AccountsTest.java index ea9a1bd..f2e9dd0 100644 --- a/src/test/java/net/tfminecraft/games/wager/AccountsTest.java +++ b/src/test/java/net/tfminecraft/games/wager/AccountsTest.java @@ -147,8 +147,7 @@ void onlineLookupReturnsOnlyConnectedOwners() { @Test void citizenTaxSinkConsumesValueWithoutEverOfferingAWithdrawal() { - MoneyAccount sink = Accounts.taxSink(); - assertSame(sink, Accounts.taxSink()); + TaxSink sink = new TaxSink(new CitizenTax.Levy(6, 6)); assertEquals("citizen tax", sink.label()); assertEquals(0, sink.available()); assertNull(sink.planTake(1, null)); @@ -156,7 +155,10 @@ void citizenTaxSinkConsumesValueWithoutEverOfferingAWithdrawal() { assertEquals(0, sink.largestTakeUpTo(100, null)); assertTrue(sink.canAccept(100)); assertEquals(0, sink.accept(null)); + assertEquals(0, sink.accept(List.of(new Stake(null, "coin", 2, 3, 0)))); + sink.authorize(6); assertEquals(6, sink.accept(List.of(new Stake(null, "coin", 2, 3, 0)))); + assertEquals(0, sink.accept(List.of(new Stake(null, "coin", 2, 3, 0)))); assertFalse(sink.onFelt()); assertFalse(sink.isTray()); assertNull(sink.feltOwner()); diff --git a/src/test/java/net/tfminecraft/games/wager/BankAccountTest.java b/src/test/java/net/tfminecraft/games/wager/BankAccountTest.java index 468a69e..a8e6f4a 100644 --- a/src/test/java/net/tfminecraft/games/wager/BankAccountTest.java +++ b/src/test/java/net/tfminecraft/games/wager/BankAccountTest.java @@ -30,7 +30,7 @@ void setUp() { chips = mockStatic(ChipItems.class); chips.when(() -> ChipItems.unitDenars(template)).thenReturn(5); chips.when(() -> ChipItems.typeKey(any(ItemStack.class))).thenReturn("coin:gold"); - bank.when(() -> GuildBank.balance("guild")).thenReturn(103); + bank.when(() -> GuildBank.balance(table)).thenReturn(103); } @AfterEach @@ -55,7 +55,7 @@ void planningRejectsNonpositiveUnrepresentableAndUnaffordableAmounts() { assertNull(account.planTake(105, template)); chips.when(() -> ChipItems.unitDenars(template)).thenReturn(0); assertNull(account.planTake(5, template)); - bank.verify(() -> GuildBank.withdraw(anyString(), anyInt()), never()); + bank.verify(() -> GuildBank.declareProfit(any(), anyString(), anyInt()), never()); } @Test @@ -77,14 +77,14 @@ void plansAndPreviewsWithoutWithdrawingThenAddsSuccessfulWithdrawalToFloat() { List preview = withdrawal.preview(); assertCoins(preview); assertEquals(12, table.houseFloat()); - bank.verify(() -> GuildBank.withdraw(anyString(), anyInt()), never()); - bank.when(() -> GuildBank.withdraw("guild", 20)).thenReturn(true); + bank.verify(() -> GuildBank.declareProfit(any(), anyString(), anyInt()), never()); + account.authorizeDebit(20); List taken = withdrawal.take(); assertCoins(taken); assertNotSame(preview.getFirst(), taken.getFirst()); assertEquals(32, table.houseFloat()); assertEquals(16, template.getAmount()); - bank.verify(() -> GuildBank.withdraw("guild", 20)); + assertTrue(withdrawal.take().isEmpty(), "posting authorization cannot be reused"); } @Test @@ -101,7 +101,7 @@ void acceptanceChecksBankOnlyForPositiveTransfers() { assertTrue(account.canAccept(-1)); bank.verifyNoInteractions(); assertFalse(account.canAccept(1)); - bank.when(() -> GuildBank.canHold("guild")).thenReturn(true); + bank.when(() -> GuildBank.canHold(table)).thenReturn(true); assertTrue(account.canAccept(1)); } @@ -112,25 +112,26 @@ void emptyOrFailedDepositsLeaveFloatAndProfitUntouched() { bank.verifyNoInteractions(); assertEquals(0, account.accept(coins(20))); assertEquals(12, table.houseFloat()); - bank.verify(() -> GuildBank.declareProfit(anyString(), anyInt()), never()); + bank.verify(() -> GuildBank.declareProfit(any(), anyString(), anyInt()), never()); } @Test void depositedWinningsRepayFloatBeforeDeclaringProfit() { table.setHouseFloat(12); - bank.when(() -> GuildBank.deposit("guild", 20)).thenReturn(true); + account.authorizeCredit(20); assertEquals(20, account.accept(coins(20))); assertEquals(0, table.houseFloat()); - bank.verify(() -> GuildBank.declareProfit("guild", 8)); + bank.verify(() -> GuildBank.declareProfit(any(), anyString(), anyInt()), never()); + assertEquals(0, account.accept(coins(20)), "posting authorization cannot be reused"); } @Test void partialFloatRepaymentDeclaresNoProfit() { table.setHouseFloat(30); - bank.when(() -> GuildBank.deposit("guild", 20)).thenReturn(true); + account.authorizeCredit(20); assertEquals(20, account.accept(coins(20))); assertEquals(10, table.houseFloat()); - bank.verify(() -> GuildBank.declareProfit(anyString(), anyInt()), never()); + bank.verify(() -> GuildBank.declareProfit(any(), anyString(), anyInt()), never()); } private void assertCoins(List coins) { diff --git a/src/test/java/net/tfminecraft/games/wager/CitizenTaxIntegrationTest.java b/src/test/java/net/tfminecraft/games/wager/CitizenTaxIntegrationTest.java index f3b9303..be0354f 100644 --- a/src/test/java/net/tfminecraft/games/wager/CitizenTaxIntegrationTest.java +++ b/src/test/java/net/tfminecraft/games/wager/CitizenTaxIntegrationTest.java @@ -2,49 +2,52 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; - +import java.util.Optional; +import java.util.UUID; +import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import org.bukkit.plugin.ServicesManager; import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.TaxLeg; +import net.tfminecraft.denareconomy.api.TaxPolicy; import net.tfminecraft.denareconomy.loaders.MessageLoader; -import net.tfminecraft.denareconomy.managers.MoneyManager; +import net.tfminecraft.tlibs.character.CharacterRef; class CitizenTaxIntegrationTest { @Test - void profitTaxDelegatesOnceAndRoundsChipWithholdingSeparatelyFromMessageAmount() { - Player player = mock(Player.class); - when(player.getName()).thenReturn("Alice"); - MoneyManager money = mock(MoneyManager.class); - when(money.doTaxes("Alice", 25)).thenReturn(2.6); - try (MockedStatic chips = mockStatic(ChipItems.class); - MockedStatic economy = mockStatic(DenarEconomy.class)) { - chips.when(ChipItems::denarEconomyPresent).thenReturn(true); - economy.when(DenarEconomy::getMoneyManager).thenReturn(money); - assertEquals(new CitizenTax.Levy(3, 2.6), CitizenTax.levy(player, 25)); - verify(money).doTaxes("Alice", 25); - assertEquals(3, CitizenTax.due(player, 25)); - verify(money, times(2)).doTaxes("Alice", 25); + void profitTaxQuotesTheCapturedCharacterWithoutCreditingAnything() { + CharacterRef character = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + TaxPolicy policy = mock(TaxPolicy.class); + ServicesManager services = mock(ServicesManager.class); + when(services.load(TaxPolicy.class)).thenReturn(policy); + when(policy.tax(character, 2500L)).thenReturn(Optional.of(new TaxLeg("guildtax:realm", 260))); + try (var bukkit = mockStatic(Bukkit.class); var denars = mockStatic(Denars.class)) { + bukkit.when(Bukkit::getServicesManager).thenReturn(services); + denars.when(Denars::taxReady).thenReturn(true); + assertEquals(new CitizenTax.Levy(3, 2.6, character, "guildtax:realm"), CitizenTax.quote(character, 25)); + verify(policy).tax(character, 2500L); + denars.verify(Denars::taxReady); + denars.verifyNoMoreInteractions(); } } @Test - void absentPlayerEconomyOrProfitNeverCallsTaxService() { - Player player = mock(Player.class); - try (MockedStatic chips = mockStatic(ChipItems.class); - MockedStatic economy = mockStatic(DenarEconomy.class)) { - assertSame(CitizenTax.Levy.NONE, CitizenTax.levy(null, 10)); - assertSame(CitizenTax.Levy.NONE, CitizenTax.levy(player, 0)); - assertSame(CitizenTax.Levy.NONE, CitizenTax.levy(player, -1)); - assertSame(CitizenTax.Levy.NONE, CitizenTax.levy(player, 10)); - economy.verifyNoInteractions(); + void absentCharacterOrTaxProviderReadinessDefersWithoutGuessing() { + CharacterRef character = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + try (var denars = mockStatic(Denars.class)) { + assertNull(CitizenTax.quote(null, 10)); + assertSame(CitizenTax.Levy.NONE, CitizenTax.quote(null, 0)); + assertSame(CitizenTax.Levy.NONE, CitizenTax.quote(character, -1)); + assertNull(CitizenTax.quote(character, 10)); + denars.verify(Denars::taxReady); } } @Test void taxNotificationOnlyReachesOnlinePlayersWithPositiveTax() { Player player = mock(Player.class); - try (MockedStatic messages = mockStatic(MessageLoader.class)) { + try (var messages = mockStatic(MessageLoader.class)) { CitizenTax.tell(null, 2); CitizenTax.tell(player, 2); when(player.isOnline()).thenReturn(true); diff --git a/src/test/java/net/tfminecraft/games/wager/CitizenTaxTest.java b/src/test/java/net/tfminecraft/games/wager/CitizenTaxTest.java index ac0c27b..513fcba 100644 --- a/src/test/java/net/tfminecraft/games/wager/CitizenTaxTest.java +++ b/src/test/java/net/tfminecraft/games/wager/CitizenTaxTest.java @@ -34,9 +34,7 @@ void chipsDueRoundsHalfUp() { } @Test - void levyIsNoneWhenDenarEconomyAbsent() { - CitizenTax.Levy levy = CitizenTax.levy(null, 100); - assertEquals(0, levy.chips()); - assertEquals(0.0, levy.tax()); + void missingCharacterDefersAProfitPayout() { + org.junit.jupiter.api.Assertions.assertNull(CitizenTax.quote(null, 100)); } } diff --git a/src/test/java/net/tfminecraft/games/wager/GuildBankTest.java b/src/test/java/net/tfminecraft/games/wager/GuildBankTest.java index b34a6dd..fd73946 100644 --- a/src/test/java/net/tfminecraft/games/wager/GuildBankTest.java +++ b/src/test/java/net/tfminecraft/games/wager/GuildBankTest.java @@ -2,146 +2,101 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; - -import java.util.logging.Logger; -import org.bukkit.Bukkit; -import org.bukkit.plugin.Plugin; -import org.bukkit.plugin.PluginManager; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import net.tfminecraft.games.Games; +import java.util.UUID; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.games.guild.GuildTables; +import net.tfminecraft.games.table.Table; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.guild.income.Ledger; import net.tfminecraft.simplefactions.managers.FactionManager; -import net.tfminecraft.simplefactions.objects.Bank; +import org.junit.jupiter.api.Test; class GuildBankTest { - private MockedStatic bukkit; - private Games previousPlugin; - private Logger logger; - private MockedStatic factions; - private Plugin plugin; - private PluginManager plugins; - private Guild guild; - private Bank bank; - - @BeforeEach - void setUp() throws ReflectiveOperationException { - previousPlugin = Games.plugin; - Games.plugin = mock(Games.class); - logger = mock(Logger.class); - when(Games.plugin.getLogger()).thenReturn(logger); - // The warning is once per server start; begin each test from a fresh start. - var loggedFail = GuildBank.class.getDeclaredField("loggedFail"); - loggedFail.setAccessible(true); - loggedFail.setBoolean(null, false); - plugins = mock(PluginManager.class); - plugin = mock(Plugin.class); - when(plugin.isEnabled()).thenReturn(true); - when(plugins.getPlugin("SimpleFactions")).thenReturn(plugin); - bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); - bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); - factions = mockStatic(FactionManager.class); - guild = mock(Guild.class); - bank = mock(Bank.class); - when(guild.getBank()).thenReturn(bank); - when(bank.getWealth()).thenReturn(10.75); - factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(guild); - } - - @AfterEach - void closeMocks() { - factions.close(); - bukkit.close(); - Games.plugin = previousPlugin; + private final UUID incarnation = UUID.randomUUID(); + private Table table() { + Table table = new Table(UUID.randomUUID(), "blackjack", null, 0, null); + table.setOwnerGuildId("guild"); + table.setOwnerGuildIncarnation(incarnation); + return table; } @Test - void missingOrDisabledIntegrationRefusesToMoveMoney() { - assertFalse(GuildBank.exists(null)); - assertFalse(GuildBank.exists(" ")); - assertFalse(GuildBank.exists("deleted")); - when(plugin.isEnabled()).thenReturn(false); - assertFalse(GuildBank.exists("guild")); - assertEquals(0, GuildBank.balance("guild")); - assertFalse(GuildBank.withdraw("guild", 1)); - assertFalse(GuildBank.deposit("guild", 1)); - assertFalse(GuildBank.canHold("guild")); - GuildBank.declareProfit("guild", 2); - when(plugins.getPlugin("SimpleFactions")).thenReturn(null); - assertFalse(GuildBank.exists("guild")); - verifyNoInteractions(bank); + void missingOrDisabledGuildIntegrationCannotFundTables() { + Table table = table(); + try (var guilds = mockStatic(GuildTables.class); var denars = mockStatic(Denars.class)) { + assertFalse(GuildBank.canHold(table)); + assertEquals(0, GuildBank.balance(table)); + assertFalse(GuildBank.declareProfit(table, "op", 10)); + denars.verifyNoInteractions(); + } } @Test - void balancesAreWholeDenarsAndWithdrawalsNeverExceedAvailableWealth() { - assertTrue(GuildBank.exists("guild")); - assertTrue(GuildBank.canHold("guild")); - assertEquals(10, GuildBank.balance("guild")); - assertFalse(GuildBank.withdraw("guild", 11)); - verify(bank, never()).withdraw(anyDouble()); - assertTrue(GuildBank.withdraw("guild", 10)); - verify(bank).withdraw(10.0); - when(bank.getWealth()).thenReturn(-5.0); - assertEquals(0, GuildBank.balance("guild")); - when(bank.getWealth()).thenReturn((double) Integer.MAX_VALUE + 100); - assertEquals(Integer.MAX_VALUE, GuildBank.balance("guild")); + void readsWholeDenarsWithoutAllowingDebtToFundAGame() { + Table table = table(); + try (var guilds = mockStatic(GuildTables.class); var denars = mockStatic(Denars.class, CALLS_REAL_METHODS)) { + guilds.when(() -> GuildTables.owns("guild", incarnation)).thenReturn(true); + denars.when(Denars::ready).thenReturn(true); + denars.when(() -> Denars.balance("guild:" + incarnation)).thenReturn(1075L, -500L, Long.MAX_VALUE); + assertEquals(10, GuildBank.balance(table)); + assertEquals(0, GuildBank.balance(table)); + assertEquals(Integer.MAX_VALUE, GuildBank.balance(table)); + } } @Test - void bankruptGuildCannotWithdrawButCanReceiveMoneyToRecover() { - when(guild.isBankrupt()).thenReturn(true); - assertEquals(0, GuildBank.balance("guild")); - assertFalse(GuildBank.withdraw("guild", 1)); - assertTrue(GuildBank.canHold("guild")); - assertTrue(GuildBank.deposit("guild", 12)); - verify(bank).deposit(12.0); - verify(bank, never()).withdraw(anyDouble()); + void indebtedGuildCanReceiveMoneyButCannotFundGames() { + Table table = table(); + try (var guilds = mockStatic(GuildTables.class); var denars = mockStatic(Denars.class, CALLS_REAL_METHODS)) { + guilds.when(() -> GuildTables.owns("guild", incarnation)).thenReturn(true); + denars.when(Denars::ready).thenReturn(true); + denars.when(() -> Denars.balance("guild:" + incarnation)).thenReturn(-100L); + assertTrue(GuildBank.canHold(table)); + assertEquals(0, GuildBank.balance(table)); + } } @Test - void missingBankOrUninitializedWealthCannotFundTable() { - when(bank.getWealth()).thenReturn(null); - assertEquals(0, GuildBank.balance("guild")); - assertFalse(GuildBank.withdraw("guild", 1)); - when(guild.getBank()).thenReturn(null); - assertFalse(GuildBank.canHold("guild")); - assertEquals(0, GuildBank.balance("guild")); - assertFalse(GuildBank.withdraw("guild", 1)); - assertFalse(GuildBank.deposit("guild", 1)); - verify(bank, never()).withdraw(anyDouble()); - verify(bank, never()).deposit(anyDouble()); + void unavailableLedgerCannotAcceptMoney() { + Table table = table(); + try (var guilds = mockStatic(GuildTables.class); var denars = mockStatic(Denars.class)) { + guilds.when(() -> GuildTables.owns("guild", incarnation)).thenReturn(true); + assertFalse(GuildBank.canHold(table)); + assertEquals(0, GuildBank.balance(table)); + } } @Test - void zeroTransfersAreNoOpsAndProfitIsDeclaredSeparatelyFromDeposits() { + void profitUsesTheStableOperationIdAndNeverMovesMoneyAgain() { + Table table = table(); + Guild guild = mock(Guild.class); Ledger ledger = mock(Ledger.class); when(guild.getLedger()).thenReturn(ledger); - assertTrue(GuildBank.withdraw("guild", 0)); - assertTrue(GuildBank.deposit("guild", 0)); - GuildBank.declareProfit("guild", 0); - verifyNoInteractions(bank, ledger); - assertTrue(GuildBank.deposit("guild", 20)); - verify(bank).deposit(20.0); - verifyNoInteractions(ledger); - GuildBank.declareProfit("guild", 7); - verify(ledger).addCasinoProfitEntry(7.0); - verify(bank, times(1)).deposit(anyDouble()); + when(ledger.addCasinoProfitEntry("games:op", 7.0)).thenReturn(true); + try (var guilds = mockStatic(GuildTables.class); var denars = mockStatic(Denars.class); + var factions = mockStatic(FactionManager.class)) { + guilds.when(() -> GuildTables.owns("guild", incarnation)).thenReturn(true); + denars.when(Denars::ready).thenReturn(true); + factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(guild); + assertTrue(GuildBank.declareProfit(table, "games:op", 0)); + verifyNoInteractions(ledger); + assertTrue(GuildBank.declareProfit(table, "games:op", 7)); + assertTrue(GuildBank.declareProfit(table, "games:op", 7)); + verify(ledger, times(2)).addCasinoProfitEntry("games:op", 7.0); + denars.verify(() -> Denars.ready(), times(2)); + denars.verifyNoMoreInteractions(); + } } @Test - void integrationFailuresFailClosedForEveryMoneyOperation() { - factions.when(() -> FactionManager.getGuildByString("guild")) - .thenThrow(new IllegalStateException("Guild data unavailable")); - assertFalse(GuildBank.exists("guild")); - assertFalse(GuildBank.canHold("guild")); - assertEquals(0, GuildBank.balance("guild")); - assertFalse(GuildBank.withdraw("guild", 5)); - assertFalse(GuildBank.deposit("guild", 5)); - assertDoesNotThrow(() -> GuildBank.declareProfit("guild", 5)); - verifyNoInteractions(bank); - verify(logger, times(1)).warning("[Games] SimpleFactions guild bank skipped: Guild data unavailable"); + void bookkeepingFailureDoesNotClaimCompletion() { + Table table = table(); + try (var guilds = mockStatic(GuildTables.class); var denars = mockStatic(Denars.class); + var factions = mockStatic(FactionManager.class)) { + guilds.when(() -> GuildTables.owns("guild", incarnation)).thenReturn(true); + denars.when(Denars::ready).thenReturn(true); + factions.when(() -> FactionManager.getGuildByString("guild")).thenThrow(new IllegalStateException("offline")); + assertFalse(GuildBank.declareProfit(table, "op", 10)); + } } } diff --git a/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java b/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java index fa47cc5..41e2820 100644 --- a/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java +++ b/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java @@ -85,6 +85,7 @@ void missingChangeDiffersFromMissingFunds() { when(source.available()).thenReturn(10); assertEquals(TxResult.Reason.NO_CHANGE, tx().move(source, destination, 5).commit().reason()); PlayerAccount player = mock(PlayerAccount.class); + when(player.character()).thenReturn(new net.tfminecraft.tlibs.character.CharacterRef(UUID.randomUUID(), UUID.randomUUID())); assertEquals(TxResult.Reason.PLAYER_SHORT, tx().move(player, destination, 5).commit().reason()); when(player.available()).thenReturn(10); assertEquals(TxResult.Reason.NO_CHANGE, tx().move(player, destination, 5).commit().reason()); diff --git a/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java b/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java index 20b48fd..1fe617a 100644 --- a/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java +++ b/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java @@ -22,6 +22,11 @@ import net.tfminecraft.games.table.Table; class WagerEngineTest { + @org.junit.jupiter.api.io.TempDir java.nio.file.Path data; + private net.tfminecraft.games.Games previousPlugin; + private MockedStatic denars; + private MockedStatic characters; + private MockedStatic managers; private WagerItemOverride oldGold; private WagerItemOverride oldSilver; private List oldItems; @@ -34,6 +39,18 @@ class WagerEngineTest { @BeforeEach void setUp() { + previousPlugin = net.tfminecraft.games.Games.plugin; + net.tfminecraft.games.Games.plugin = mock(net.tfminecraft.games.Games.class); + when(net.tfminecraft.games.Games.plugin.getDataFolder()).thenReturn(data.toFile()); + when(net.tfminecraft.games.Games.plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + WagerJournal.initialize(); + denars = mockStatic(net.tfminecraft.denareconomy.api.Denars.class); + denars.when(net.tfminecraft.denareconomy.api.Denars::taxReady).thenReturn(true); + denars.when(() -> net.tfminecraft.denareconomy.api.Denars.post(anyString(), anyString(), anyList())) + .thenReturn(net.tfminecraft.denareconomy.api.Result.APPLIED); + managers = mockStatic(net.tfminecraft.games.table.TableManager.class); + managers.when(net.tfminecraft.games.table.TableManager::get).thenReturn(mock(net.tfminecraft.games.table.TableManager.class)); + characters = net.tfminecraft.games.support.TestCharacters.mockSessions(); previousEngine = WagerEngine.get(); oldGold = Cache.wagerGold; oldSilver = Cache.wagerSilver; @@ -48,10 +65,16 @@ void setUp() { winner = MockBukkit.getMock().addPlayer(); opponent = MockBukkit.getMock().addPlayer(); table = new Table(UUID.randomUUID(), "poker", winner.getLocation(), 0, null); + table.wagerCharacters().put(winner.getUniqueId(), net.tfminecraft.games.support.TestCharacters.ref(winner)); + table.wagerCharacters().put(opponent.getUniqueId(), net.tfminecraft.games.support.TestCharacters.ref(opponent)); } @AfterEach void restoreConfig() throws Exception { + denars.close(); + characters.close(); + managers.close(); + net.tfminecraft.games.Games.plugin = previousPlugin; var instance = WagerEngine.class.getDeclaredField("instance"); instance.setAccessible(true); instance.set(null, previousEngine); @@ -225,7 +248,7 @@ void sweptPotReturnsOriginalStakeTaxesOnlyCoinProfitAndPreservesLoot() { 1, 1, null, null); restore(table.getId(), 5, 1); try (MockedStatic tax = mockStatic(CitizenTax.class)) { - tax.when(() -> CitizenTax.levy(winner, 6)).thenReturn(new CitizenTax.Levy(2, 2)); + tax.when(() -> CitizenTax.quote(net.tfminecraft.games.support.TestCharacters.ref(winner), 6)).thenReturn(new CitizenTax.Levy(2, 2)); TxResult result = engine.sweepPot(table, winner, winner.getUniqueId(), null, "winner"); assertEquals(18, result.moved()); assertEquals(6, inventoryCoins(winner)); @@ -236,7 +259,7 @@ void sweptPotReturnsOriginalStakeTaxesOnlyCoinProfitAndPreservesLoot() { assertEquals(6, table.roundMoney().wonProfit().get(winner.getUniqueId())); assertEquals(2, table.roundMoney().moneyIn(winner.getUniqueId())); assertEquals(6, table.roundMoney().moneyOut(winner.getUniqueId())); - tax.verify(() -> CitizenTax.levy(winner, 6)); + tax.verify(() -> CitizenTax.quote(net.tfminecraft.games.support.TestCharacters.ref(winner), 6)); tax.verify(() -> CitizenTax.tell(winner, 2)); } } @@ -246,16 +269,16 @@ void stagedPotPayoutDoesNotTaxTheReturnedStakeTwice() { bet(winner, 4); bet(opponent, 6); try (MockedStatic tax = mockStatic(CitizenTax.class)) { - tax.when(() -> CitizenTax.levy(winner, 0)).thenReturn(CitizenTax.Levy.NONE); - tax.when(() -> CitizenTax.levy(winner, 3)).thenReturn(new CitizenTax.Levy(1, 1)); + tax.when(() -> CitizenTax.quote(net.tfminecraft.games.support.TestCharacters.ref(winner), 0)).thenReturn(CitizenTax.Levy.NONE); + tax.when(() -> CitizenTax.quote(net.tfminecraft.games.support.TestCharacters.ref(winner), 3)).thenReturn(new CitizenTax.Levy(1, 1)); assertEquals(3, engine.payFromPot(table, winner, winner.getUniqueId(), 3, null, "first share").moved()); assertEquals(4, engine.payFromPot(table, winner, winner.getUniqueId(), 4, null, "second share").moved()); assertEquals(6, inventoryCoins(winner)); assertEquals(3, engine.felt(table)); - tax.verify(() -> CitizenTax.levy(winner, 0)); - tax.verify(() -> CitizenTax.levy(winner, 3)); + tax.verify(() -> CitizenTax.quote(net.tfminecraft.games.support.TestCharacters.ref(winner), 0)); + tax.verify(() -> CitizenTax.quote(net.tfminecraft.games.support.TestCharacters.ref(winner), 3)); } } @@ -292,7 +315,7 @@ void houseBackedWinTaxesProfitAndUsesMintForTrayShortfall() { table.setStaffMint(true); restore(table.getId(), 3, 1); try (MockedStatic tax = mockStatic(CitizenTax.class)) { - tax.when(() -> CitizenTax.levy(winner, 8)).thenReturn(new CitizenTax.Levy(2, 2)); + tax.when(() -> CitizenTax.quote(net.tfminecraft.games.support.TestCharacters.ref(winner), 8)).thenReturn(new CitizenTax.Levy(2, 2)); PayWinResult result = engine.payWin(table, winner, winner.getUniqueId(), 8, null, false, coin(1), null); assertEquals(new PayWinResult(8, 3, 0), result); @@ -364,19 +387,13 @@ void absentOrOfflineDealerNeverFallsBackToStaffMintForPersonalDealerDebt() { @Test void fullyWithheldPayoutReportsTaxOnlyWhenCoinsActuallyLeaveThePotOrTray() { try (MockedStatic tax = mockStatic(CitizenTax.class)) { - tax.when(() -> CitizenTax.levy(winner, 2)).thenReturn(new CitizenTax.Levy(2, 2)); - assertEquals(0, engine.payFromPot(table, winner, winner.getUniqueId(), 2, - null, "empty pot").moved()); - tax.verify(() -> CitizenTax.tell(winner, 2), never()); + tax.when(() -> CitizenTax.quote(net.tfminecraft.games.support.TestCharacters.ref(winner), 2)).thenReturn(new CitizenTax.Levy(2, 2)); restore(opponent.getUniqueId(), 2, 1); assertEquals(2, engine.payFromPot(table, winner, winner.getUniqueId(), 2, null, "withheld share").moved()); assertEquals(0, inventoryCoins(winner)); assertEquals(0, engine.felt(table)); tax.verify(() -> CitizenTax.tell(winner, 2)); - assertEquals(new PayWinResult(0, 0, 2), engine.payWin(table, winner, winner.getUniqueId(), - 2, null, true, coin(1), null)); - tax.verify(() -> CitizenTax.tell(winner, 2)); restore(table.getId(), 2, 1); assertEquals(new PayWinResult(2, 2, 0), engine.payWin(table, winner, winner.getUniqueId(), 2, null, true, coin(1), null)); @@ -454,7 +471,7 @@ void potContainingOnlyReturnedPrincipalHasNoWinningsToAnnounce() { void partlyWithheldDealerPayoutRecordsTransferredProfitAndRemainingDebt() { restore(table.getId(), 8, 1); try (MockedStatic tax = mockStatic(CitizenTax.class)) { - tax.when(() -> CitizenTax.levy(winner, 10)).thenReturn(new CitizenTax.Levy(3, 3)); + tax.when(() -> CitizenTax.quote(net.tfminecraft.games.support.TestCharacters.ref(winner), 10)).thenReturn(new CitizenTax.Levy(3, 3)); PayWinResult result = engine.payWin(table, winner, winner.getUniqueId(), 10, null, true, coin(1), null); assertEquals(new PayWinResult(8, 8, 2), result); @@ -471,7 +488,7 @@ void partlyWithheldPotPayoutExcludesPrincipalAndUnfundedProfit() { bet(winner, 4); bet(opponent, 5); try (MockedStatic tax = mockStatic(CitizenTax.class)) { - tax.when(() -> CitizenTax.levy(winner, 6)).thenReturn(new CitizenTax.Levy(2, 2)); + tax.when(() -> CitizenTax.quote(net.tfminecraft.games.support.TestCharacters.ref(winner), 6)).thenReturn(new CitizenTax.Levy(2, 2)); TxResult result = engine.payFromPot(table, winner, winner.getUniqueId(), 10, null, "partial taxed pot"); assertEquals(9, result.moved()); @@ -518,4 +535,57 @@ void savedStakeWithHalfAnAnchorKeepsItsMoneyButNoAnchor() { assertEquals(3, engine.owned(table, owner)); assertFalse(table.ledger().hasAnchor(owner)); } + + @Test void missingCapturedCharacterKeepsWinningObligationAcrossRoundClear() throws Exception { + table.wagerCharacters().remove(winner.getUniqueId()); + table.startSession(); + table.setStaffMint(true); + assertEquals(new PayWinResult(0, 0, 5), engine.payWin(table, winner, winner.getUniqueId(), 5, + null, false, coin(1), null)); + assertTrue(table.moneyBlocked()); + table.clearSession(); + assertTrue(table.live()); + String review = java.nio.file.Files.readString(data.resolve("Data/wager-intents/" + table.getId() + ".json")); + assertTrue(review.contains(winner.getUniqueId().toString())); + assertTrue(com.google.gson.JsonParser.parseString(review).getAsJsonObject().getAsJsonArray("unpaidOutcomes").get(0).getAsJsonObject().get("reason").getAsString().contains("profit=5")); + assertTrue(review.contains("operationId")); + denars.verify(() -> net.tfminecraft.denareconomy.api.Denars.post(anyString(), anyString(), anyList()), never()); + } + + @Test void policyWaitMidRoundRetainsEveryUnpaidWinningOutcome() throws Exception { + denars.when(net.tfminecraft.denareconomy.api.Denars::taxReady).thenReturn(false); + table.setStaffMint(true); + assertEquals(5, engine.payWin(table, winner, winner.getUniqueId(), 5, null, false, coin(1), null).owe()); + assertEquals(7, engine.payWin(table, opponent, opponent.getUniqueId(), 7, null, false, coin(1), null).owe()); + var review = com.google.gson.JsonParser.parseString(java.nio.file.Files.readString( + data.resolve("Data/wager-intents/" + table.getId() + ".json"))).getAsJsonObject(); + assertEquals(2, review.getAsJsonArray("unpaidOutcomes").size()); + assertTrue(review.toString().contains(net.tfminecraft.games.support.TestCharacters.ref(winner).characterId().toString())); + assertTrue(review.toString().contains(net.tfminecraft.games.support.TestCharacters.ref(opponent).characterId().toString())); + assertEquals(0, inventoryCoins(winner)); + assertEquals(0, inventoryCoins(opponent)); + } + + @Test void differentCharacterOnWinningAccountCannotReceiveOldCharactersPayout() { + restore(opponent.getUniqueId(), 5, 1); + var other = new net.tfminecraft.tlibs.character.CharacterRef(winner.getUniqueId(), UUID.randomUUID()); + characters.when(() -> net.tfminecraft.tlibs.character.Characters.currentSession(winner)) + .thenReturn(java.util.Optional.of(new net.tfminecraft.tlibs.character.CharacterSession(other, 2L))); + assertFalse(engine.payFromPot(table, winner, winner.getUniqueId(), 5, null, "win").ok()); + assertEquals(5, engine.felt(table)); + assertEquals(0, inventoryCoins(winner)); + assertTrue(table.moneyBlocked()); + } + + @Test void settledRoundAllowsSameAccountToStakeAsANewCharacterWithHouseTrayRemaining() { + restore(table.getId(), 3, 1); + table.clearSession(); + assertTrue(table.wagerCharacters().isEmpty()); + var next = new net.tfminecraft.tlibs.character.CharacterRef(winner.getUniqueId(), UUID.randomUUID()); + characters.when(() -> net.tfminecraft.tlibs.character.Characters.currentSession(winner)) + .thenReturn(java.util.Optional.of(new net.tfminecraft.tlibs.character.CharacterSession(next, 2L))); + bet(winner, 2); + assertEquals(next, table.wagerCharacters().get(winner.getUniqueId())); + assertEquals(3, engine.tray(table)); + } } diff --git a/src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java b/src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java new file mode 100644 index 0000000..8c7c0aa --- /dev/null +++ b/src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java @@ -0,0 +1,258 @@ +package net.tfminecraft.games.wager; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import com.google.gson.JsonParser; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.games.Games; +import net.tfminecraft.games.cache.Cache; +import net.tfminecraft.games.support.TestCharacters; +import net.tfminecraft.games.table.Table; +import net.tfminecraft.games.table.TableManager; +import net.tfminecraft.tlibs.character.CharacterRef; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class WagerJournalTest { + @TempDir Path data; + private final List scopes = new ArrayList<>(); + private Games previousPlugin; + private WagerItemOverride oldGold, oldSilver; + private List oldItems; + private Table table; + private TableManager manager; + private PlayerMock winner; + private MockedStatic denars; + private MockedStatic bank; + private final List operations = new ArrayList<>(); + private final List> postings = new ArrayList<>(); + private final AtomicReference outcome = new AtomicReference<>(Result.APPLIED); + private final WagerHost host = mock(WagerHost.class); + + private T scope(T item) { scopes.addFirst(item); return item; } + + @BeforeEach void setup() { + previousPlugin = Games.plugin; + Games.plugin = mock(Games.class); + when(Games.plugin.getDataFolder()).thenReturn(data.toFile()); + when(Games.plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + WagerJournal.initialize(); + oldGold = Cache.wagerGold; oldSilver = Cache.wagerSilver; + oldItems = new ArrayList<>(Cache.wagerItems); + Cache.wagerGold = new WagerItemOverride("GOLD_NUGGET", 1, null, null, null, null, null, null, false, null); + Cache.wagerSilver = null; Cache.wagerItems.clear(); + winner = MockBukkit.getMock().addPlayer(); + table = new Table(UUID.randomUUID(), "blackjack", winner.getLocation(), 0, null); + table.setOwnerGuildId("guild"); + table.setOwnerGuildIncarnation(UUID.randomUUID()); + table.wagerCharacters().put(winner.getUniqueId(), TestCharacters.ref(winner)); + scope(TestCharacters.mockSessions()); + manager = mock(TableManager.class); + var managers = scope(mockStatic(TableManager.class)); + managers.when(TableManager::get).thenReturn(manager); + bank = scope(mockStatic(GuildBank.class)); + bank.when(() -> GuildBank.account(table)).thenReturn("guild:" + table.ownerGuildIncarnation()); + bank.when(() -> GuildBank.balance(table)).thenReturn(100); + bank.when(() -> GuildBank.canHold(table)).thenReturn(true); + bank.when(() -> GuildBank.declareProfit(eq(table), anyString(), anyInt())).thenReturn(true); + denars = scope(mockStatic(Denars.class)); + denars.when(Denars::taxReady).thenReturn(true); + denars.when(() -> Denars.post(anyString(), anyString(), anyList())).thenAnswer(call -> { + assertTrue(Files.exists(journal()), "Intent must exist before posting"); + operations.add(call.getArgument(0)); postings.add(List.copyOf(call.getArgument(2))); + return outcome.get(); + }); + } + + @AfterEach void cleanup() throws Exception { + for (var item : scopes) item.close(); + Games.plugin = previousPlugin; + Cache.wagerGold = oldGold; Cache.wagerSilver = oldSilver; + Cache.wagerItems.clear(); Cache.wagerItems.addAll(oldItems); + WagerJournal.initialize(); + } + + private Path journal() { return data.resolve("Data/wager-intents/" + table.getId() + ".json"); } + private ItemStack coin() { return new ItemStack(Material.GOLD_NUGGET); } + private MoneyTx funding(int amount) { + return new MoneyTx(host, table, "fund").move(Accounts.bank(table), Accounts.tray(table), amount, coin()); + } + private long credit(String account) { + return postings.getLast().stream().filter(leg -> leg.account().equals(account)).mapToLong(Leg::cents).sum(); + } + + @Test void bankWinAndActualWithheldTaxAreOneBalancedPosting() throws Exception { + CharacterRef earner = TestCharacters.ref(winner); + TaxSink tax = new TaxSink(new CitizenTax.Levy(3, 2.6, earner, "guildtax:province")); + BankAccount source = Accounts.bank(table); + MoneyTx tx = new MoneyTx(host, table, "win") + .spread(Accounts.payee(table, winner, winner.getUniqueId()), 7, List.of(source), coin()) + .spread(tax, 3, List.of(source), coin()); + assertEquals(10, tx.commit().moved()); + assertEquals(1, postings.size()); + assertEquals(-1000, credit("guild:" + table.ownerGuildIncarnation())); + assertEquals(700, credit(Denars.MINT)); + assertEquals(300, credit("guildtax:province")); + assertEquals(0, postings.getFirst().stream().mapToLong(Leg::cents).sum()); + assertEquals(7, Accounts.coins(table, winner).available()); + assertEquals(3, tax.received()); + assertEquals(10, table.houseFloat()); + assertFalse(Files.exists(journal())); + verify(manager).saveWager(table); + assertEquals(10, tx.commit().moved()); + assertEquals(1, postings.size(), "Repeated commit cannot deliver again"); + } + + @Test void refusedPostingLeavesItemsFloatAndProfitUntouched() { + table.setHouseFloat(2); + table.ledger().add(table.getId(), coin(), "gold", 1, 5, 0); + outcome.set(Result.REFUSED); + var tx = new MoneyTx(host, table, "peel").moveAll(Accounts.tray(table), Accounts.bank(table)); + assertFalse(tx.commit().ok()); + assertEquals(5, table.ledger().total()); + assertEquals(2, table.houseFloat()); + assertFalse(Files.exists(journal())); + verifyNoInteractions(manager, host); + bank.verify(() -> GuildBank.declareProfit(eq(table), anyString(), anyInt()), never()); + } + + @Test void unavailableRetryUsesSamePersistedOperationAndDeliversOnce() throws Exception { + outcome.set(Result.UNAVAILABLE); + MoneyTx tx = funding(5); + assertFalse(tx.commit().ok()); + String before = Files.readString(journal()); + assertTrue(before.contains("prepared")); + assertEquals(0, table.ledger().total()); + assertEquals(0, table.houseFloat()); + outcome.set(Result.APPLIED); + assertEquals(5, tx.commit().moved()); + assertEquals(2, operations.size()); + assertEquals(operations.getFirst(), operations.getLast()); + assertEquals(postings.getFirst(), postings.getLast()); + assertEquals(5, table.ledger().total()); + assertFalse(Files.exists(journal())); + } + + @Test void knownReadinessWaitDoesNotCreateIntent() { + denars.when(Denars::taxReady).thenReturn(false); + assertFalse(funding(5).commit().ok()); + assertFalse(Files.exists(journal())); + assertEquals(0, table.houseFloat()); + assertTrue(postings.isEmpty()); + } + + @Test void restartRetainsPreparedIntentWithoutPostingOrDeliveringAgain() throws Exception { + outcome.set(Result.UNAVAILABLE); + assertFalse(funding(5).commit().ok()); + String before = Files.readString(journal()); + WagerJournal.initialize(); + outcome.set(Result.APPLIED); + assertFalse(funding(5).commit().ok()); + assertEquals(before, Files.readString(journal())); + assertEquals(1, postings.size()); + assertEquals(0, table.ledger().total()); + } + + @Test void appliedStatusAfterUncertainReplyDoesNotPostAgain() { + outcome.set(Result.UNAVAILABLE); + MoneyTx tx = funding(5); + assertFalse(tx.commit().ok()); + denars.when(() -> Denars.status(operations.getFirst())).thenReturn(Optional.of(Result.APPLIED)); + assertEquals(5, tx.commit().moved()); + assertEquals(1, postings.size()); + } + + @Test void conflictRemainsForReviewAndBlocksFurtherMoney() throws Exception { + outcome.set(Result.CONFLICT); + assertFalse(funding(5).commit().ok()); + assertEquals("conflict", JsonParser.parseString(Files.readString(journal())).getAsJsonObject().get("state").getAsString()); + outcome.set(Result.APPLIED); + assertFalse(funding(5).commit().ok()); + assertEquals(1, postings.size()); + assertEquals(0, table.houseFloat()); + } + + @Test void physicalSourceChangedDuringPostDoesNotConsumeChangedItems() throws Exception { + table.ledger().add(table.getId(), coin(), "gold", 1, 5, 0); + denars.when(() -> Denars.post(anyString(), anyString(), anyList())).thenAnswer(call -> { + table.ledger().add(table.getId(), coin(), "gold", 1, 1, 0); + return Result.APPLIED; + }); + assertFalse(new MoneyTx(host, table, "peel").moveAll(Accounts.tray(table), Accounts.bank(table)).commit().ok()); + assertEquals(6, table.ledger().total()); + assertTrue(table.moneyBlocked()); + assertTrue(Files.readString(journal()).contains("applying")); + assertEquals(0, table.houseFloat()); + } + + @Test void failedTableSavePreservesAmbiguousDeliveryAndBlocksSecondPayment() throws Exception { + doThrow(new IOException("disk full")).when(manager).saveWager(table); + assertFalse(funding(5).commit().ok()); + assertEquals(5, table.ledger().total()); + assertEquals(5, table.houseFloat()); + assertTrue(table.moneyBlocked()); + assertTrue(Files.exists(journal())); + assertFalse(funding(5).commit().ok()); + assertEquals(1, postings.size()); + } + + @Test void casinoProfitRecordsOnlySurplusAfterFloatWithPostingOperationId() { + table.setHouseFloat(2); + table.ledger().add(table.getId(), coin(), "gold", 1, 5, 0); + assertEquals(5, new MoneyTx(host, table, "peel").moveAll(Accounts.tray(table), Accounts.bank(table)).commit().moved()); + assertEquals(0, table.houseFloat()); + assertEquals(500, credit("guild:" + table.ownerGuildIncarnation())); + bank.verify(() -> GuildBank.declareProfit(table, operations.getFirst(), 3)); + } + + @Test void failedProfitBookkeepingRetainsAppliedIntentWithoutRepeatingMoney() throws Exception { + table.ledger().add(table.getId(), coin(), "gold", 1, 5, 0); + bank.when(() -> GuildBank.declareProfit(eq(table), anyString(), eq(5))).thenReturn(false); + assertFalse(new MoneyTx(host, table, "peel").moveAll(Accounts.tray(table), Accounts.bank(table)).commit().ok()); + assertEquals(0, table.ledger().total()); + assertTrue(table.moneyBlocked()); + assertTrue(Files.exists(journal())); + verify(manager).saveWager(table); + assertEquals(1, postings.size()); + } + + @Test void failureWritingIntentNeverPostsOrMovesItems() throws Exception { + Files.createDirectories(journal().getParent().getParent()); + Files.writeString(journal().getParent(), "not a directory"); + assertFalse(funding(5).commit().ok()); + assertTrue(postings.isEmpty()); + assertEquals(0, table.houseFloat()); + assertEquals(0, table.ledger().total()); + assertTrue(table.moneyBlocked()); + } + + @Test void freshHandlesToOneSourceCannotOverReserveIt() { + table.ledger().add(table.getId(), coin(), "gold", 1, 5, 0); + TaxSink tax = new TaxSink(new CitizenTax.Levy(3, 3, TestCharacters.ref(winner), "guildtax:province")); + var result = new MoneyTx(host, table, "shared source") + .move(Accounts.tray(table), Accounts.payee(table, winner, winner.getUniqueId()), 4) + .move(Accounts.tray(table), tax, 3).commit(); + assertFalse(result.ok()); + assertEquals(5, table.ledger().total()); + assertEquals(0, Accounts.coins(table, winner).available()); + assertTrue(postings.isEmpty()); + } +} From 4d23148355b7efffd30d572bd39e5be67930b160 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:12:15 +0000 Subject: [PATCH 2/5] fix: persist payment intent directory entries before posting --- .../java/net/tfminecraft/games/persistence/DurableFiles.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java b/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java index dc8679d..27cc30f 100644 --- a/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java +++ b/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java @@ -18,6 +18,7 @@ public static void write(Path target, String text) throws IOException { } public static void write(Path target, byte[] bytes) throws IOException { + target = target.toAbsolutePath(); Files.createDirectories(target.getParent()); Path temp = Files.createTempFile(target.getParent(), ".pending-", ".tmp"); try { @@ -27,7 +28,8 @@ public static void write(Path target, byte[] bytes) throws IOException { channel.force(true); } Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - sync(target.getParent()); + // Parent entries also need flushing when an intent creates its first directory. + for (Path directory = target.getParent(); directory != null; directory = directory.getParent()) sync(directory); } finally { Files.deleteIfExists(temp); } From c98222cfbdada42ec73c936dd911f9b054f90a6f Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:26:51 +0000 Subject: [PATCH 3/5] fix: retry guild table migration after a failed incarnation lookup --- .../games/guild/GuildTableMigration.java | 5 ++- .../tfminecraft/games/guild/GuildTables.java | 22 ++++++++--- .../games/guild/GuildTableMigrationTest.java | 39 ++++++++++++++----- .../games/guild/GuildTablesTest.java | 23 +++++++++++ 4 files changed, 74 insertions(+), 15 deletions(-) diff --git a/src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java b/src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java index 0debb84..16c5cd2 100644 --- a/src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java +++ b/src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java @@ -25,9 +25,12 @@ public static boolean prepare(Path tableFile, Path migrationFolder) throws IOExc // A complete plan is written before the table. Replay the exact incarnation, never resolve again. migrated = JsonParser.parseString(Files.readString(manifest)).getAsJsonObject(); } else { + GuildTables.Lookup lookup = GuildTables.resolveIncarnation(data.get("ownerGuildId").getAsString()); + // A failed lookup is not a deleted guild: stamp nothing and let a later pass retry. + if (lookup.failed()) return false; if (!Files.exists(backup)) DurableFiles.write(backup, Files.readAllBytes(tableFile)); migrated = data.deepCopy(); - UUID incarnation = GuildTables.incarnation(data.get("ownerGuildId").getAsString()); + UUID incarnation = lookup.incarnation(); if (incarnation != null) migrated.addProperty("ownerGuildIncarnation", incarnation.toString()); migrated.addProperty("ownerGuildMigrated", true); DurableFiles.write(manifest, migrated.toString()); diff --git a/src/main/java/net/tfminecraft/games/guild/GuildTables.java b/src/main/java/net/tfminecraft/games/guild/GuildTables.java index be2ce70..42bdd44 100644 --- a/src/main/java/net/tfminecraft/games/guild/GuildTables.java +++ b/src/main/java/net/tfminecraft/games/guild/GuildTables.java @@ -205,17 +205,29 @@ public static boolean leadsOwner(Table table, Player player) { return owns(guildId, table.ownerGuildIncarnation()) && isLeader(guildId, player); } - public static UUID incarnation(String guildId) { - if (guildId == null || guildId.isBlank()) return null; + /** An incarnation lookup that keeps "no such guild" apart from "could not ask". */ + public record Lookup(UUID incarnation, boolean failed) {} + + public static Lookup resolveIncarnation(String guildId) { + if (guildId == null || guildId.isBlank()) return new Lookup(null, false); try { - Guild guild = guild(guildId); - return guild == null ? null : guild.getIncarnationId(); + if (!sfReady()) return new Lookup(null, true); + Guild guild = FactionManager.getGuildByString(guildId); + if (guild == null) return new Lookup(null, false); + UUID incarnation = guild.getIncarnationId(); + // A live guild without an incarnation is not proof of deletion; ask again later. + return incarnation == null ? new Lookup(null, true) : new Lookup(incarnation, false); } catch (RuntimeException | LinkageError ex) { warn(ex); - return null; + return new Lookup(null, true); } } + public static UUID incarnation(String guildId) { + Lookup lookup = resolveIncarnation(guildId); + return lookup.failed() ? null : lookup.incarnation(); + } + public static boolean owns(String guildId, UUID incarnation) { return incarnation != null && incarnation.equals(incarnation(guildId)); } diff --git a/src/test/java/net/tfminecraft/games/guild/GuildTableMigrationTest.java b/src/test/java/net/tfminecraft/games/guild/GuildTableMigrationTest.java index a1b7bcf..e25f710 100644 --- a/src/test/java/net/tfminecraft/games/guild/GuildTableMigrationTest.java +++ b/src/test/java/net/tfminecraft/games/guild/GuildTableMigrationTest.java @@ -16,6 +16,9 @@ private Path source() throws Exception { Files.writeString(source, "{\"ownerGuildId\":\"guild\",\"houseFloat\":17}"); return source; } + private static GuildTables.Lookup found(UUID incarnation) { + return new GuildTables.Lookup(incarnation, false); + } @Test void waitsWithoutBackupOrMutationUntilFactionMigrationIsReady() throws Exception { Path table = source(); String before = Files.readString(table); @@ -23,7 +26,23 @@ private Path source() throws Exception { assertFalse(GuildTableMigration.prepare(table, data.resolve("migration"))); assertEquals(before, Files.readString(table)); assertFalse(Files.exists(data.resolve("migration"))); - guilds.verify(() -> GuildTables.incarnation(anyString()), never()); + guilds.verify(() -> GuildTables.resolveIncarnation(anyString()), never()); + } + } + @Test void failedLookupStampsNothingAndALaterPassStillMigrates() throws Exception { + Path table = source(); + String before = Files.readString(table); + UUID incarnation = UUID.randomUUID(); + try (var guilds = mockStatic(GuildTables.class)) { + guilds.when(GuildTables::ready).thenReturn(true); + guilds.when(() -> GuildTables.resolveIncarnation("guild")) + .thenReturn(new GuildTables.Lookup(null, true)); + assertFalse(GuildTableMigration.prepare(table, data.resolve("migration"))); + assertEquals(before, Files.readString(table)); + assertFalse(Files.exists(data.resolve("migration")), "a transient failure writes no manifest"); + guilds.when(() -> GuildTables.resolveIncarnation("guild")).thenReturn(found(incarnation)); + assertTrue(GuildTableMigration.prepare(table, data.resolve("migration"))); + assertTrue(Files.readString(table).contains(incarnation.toString())); } } @Test void recordsImmutableBackupAndNeverAdoptsAReplacementGuild() throws Exception { @@ -32,16 +51,16 @@ private Path source() throws Exception { UUID incarnation = UUID.randomUUID(); try (var guilds = mockStatic(GuildTables.class)) { guilds.when(GuildTables::ready).thenReturn(true); - guilds.when(() -> GuildTables.incarnation("guild")).thenReturn(incarnation); + guilds.when(() -> GuildTables.resolveIncarnation("guild")).thenReturn(found(incarnation)); assertTrue(GuildTableMigration.prepare(table, data.resolve("migration"))); String migrated = Files.readString(table); assertEquals(before, Files.readString(data.resolve("migration/backup/table.json"))); assertTrue(migrated.contains(incarnation.toString())); assertEquals(17, JsonParser.parseString(migrated).getAsJsonObject().get("houseFloat").getAsInt()); - guilds.when(() -> GuildTables.incarnation("guild")).thenReturn(UUID.randomUUID()); + guilds.when(() -> GuildTables.resolveIncarnation("guild")).thenReturn(found(UUID.randomUUID())); assertTrue(GuildTableMigration.prepare(table, data.resolve("migration"))); assertEquals(migrated, Files.readString(table)); - guilds.verify(() -> GuildTables.incarnation("guild"), times(1)); + guilds.verify(() -> GuildTables.resolveIncarnation("guild"), times(1)); } } @Test void interruptedPlanReplaysOriginalIncarnationAndNeverResolvesAgain() throws Exception { @@ -50,28 +69,30 @@ private Path source() throws Exception { UUID incarnation = UUID.randomUUID(); try (var guilds = mockStatic(GuildTables.class)) { guilds.when(GuildTables::ready).thenReturn(true); - guilds.when(() -> GuildTables.incarnation("guild")).thenReturn(incarnation); + guilds.when(() -> GuildTables.resolveIncarnation("guild")).thenReturn(found(incarnation)); assertTrue(GuildTableMigration.prepare(table, data.resolve("migration"))); String migrated = Files.readString(table); Files.writeString(table, before); - guilds.when(() -> GuildTables.incarnation("guild")).thenReturn(UUID.randomUUID()); + guilds.when(() -> GuildTables.resolveIncarnation("guild")).thenReturn(found(UUID.randomUUID())); assertTrue(GuildTableMigration.prepare(table, data.resolve("migration"))); assertEquals(migrated, Files.readString(table)); - guilds.verify(() -> GuildTables.incarnation("guild"), times(1)); + guilds.verify(() -> GuildTables.resolveIncarnation("guild"), times(1)); } } @Test void missingGuildIsPermanentlyUnresolvedAndPersonalTablesNeedNoMigration() throws Exception { Path table = source(); try (var guilds = mockStatic(GuildTables.class)) { guilds.when(GuildTables::ready).thenReturn(true); + guilds.when(() -> GuildTables.resolveIncarnation("guild")) + .thenReturn(new GuildTables.Lookup(null, false)); assertTrue(GuildTableMigration.prepare(table, data.resolve("migration"))); var migrated = JsonParser.parseString(Files.readString(table)).getAsJsonObject(); assertTrue(migrated.get("ownerGuildMigrated").getAsBoolean()); assertFalse(migrated.has("ownerGuildIncarnation")); - guilds.when(() -> GuildTables.incarnation("guild")).thenReturn(UUID.randomUUID()); + guilds.when(() -> GuildTables.resolveIncarnation("guild")).thenReturn(found(UUID.randomUUID())); assertTrue(GuildTableMigration.prepare(table, data.resolve("migration"))); assertEquals(migrated, JsonParser.parseString(Files.readString(table))); - guilds.verify(() -> GuildTables.incarnation("guild"), times(1)); + guilds.verify(() -> GuildTables.resolveIncarnation("guild"), times(1)); Files.writeString(table, "{}"); guilds.when(GuildTables::ready).thenReturn(false); assertTrue(GuildTableMigration.prepare(table, data.resolve("unused"))); diff --git a/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java b/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java index 39e6b23..32cabc9 100644 --- a/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java +++ b/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java @@ -313,6 +313,29 @@ private static Table table(String guild) { assertTrue(GuildTables.frozen(table)); } + @Test void incarnationLookupSeparatesAbsentGuildsFromFailedLookups() { + assertEquals(new GuildTables.Lookup(INCARNATION, false), GuildTables.resolveIncarnation("guild")); + assertEquals(new GuildTables.Lookup(null, false), GuildTables.resolveIncarnation("deleted")); + assertEquals(new GuildTables.Lookup(null, false), GuildTables.resolveIncarnation(null)); + assertEquals(new GuildTables.Lookup(null, false), GuildTables.resolveIncarnation(" ")); + assertNull(GuildTables.incarnation("deleted")); + + when(guild.getIncarnationId()).thenReturn(null); + assertTrue(GuildTables.resolveIncarnation("guild").failed(), + "a live guild with no incarnation yet is retried, not treated as deleted"); + when(guild.getIncarnationId()).thenReturn(INCARNATION); + + api.when(SimpleFactionsApi::ready).thenReturn(false); + assertTrue(GuildTables.resolveIncarnation("guild").failed(), + "factions not ready cannot prove a guild is gone"); + api.when(SimpleFactionsApi::ready).thenReturn(true); + + factions.when(() -> FactionManager.getGuildByString("guild")) + .thenThrow(new IllegalStateException("integration unavailable")); + assertTrue(GuildTables.resolveIncarnation("guild").failed()); + assertNull(GuildTables.incarnation("guild"), "runtime lookups still fail closed"); + } + @Test void replacementGuildCannotUseOrCountAnOldIncarnationAndAccountNamesGiveNoRights() { Table table = table("guild"); tables.add(table); From 7da9e48b953feac69a1fd4b17b445605a02a98c2 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:26:51 +0000 Subject: [PATCH 4/5] fix: do not quarantine tables for payout legs that move no money --- .../net/tfminecraft/games/wager/MoneyTx.java | 16 ++++--- .../table/TableManagerInteractionTest.java | 1 + .../tfminecraft/games/wager/MoneyTxTest.java | 46 +++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/main/java/net/tfminecraft/games/wager/MoneyTx.java b/src/main/java/net/tfminecraft/games/wager/MoneyTx.java index 694cd19..6f54335 100644 --- a/src/main/java/net/tfminecraft/games/wager/MoneyTx.java +++ b/src/main/java/net/tfminecraft/games/wager/MoneyTx.java @@ -189,13 +189,6 @@ public TxResult commit() { MoneyAccount prior = sources.putIfAbsent(sourceKey, leg.from); if (prior != null && prior != leg.from) return TxResult.failed(TxResult.Reason.NO_CHANGE, leg.denars, 0); } - if (leg.to instanceof PlayerAccount recipient && recipient.flightTarget() != null) { - CharacterRef expected = table.wagerCharacters().get(recipient.flightTarget()); - if (expected == null || (recipient.online() && !expected.equals(recipient.character()))) { - WagerJournal.quarantine(table, "Payout character changed: owner=" + recipient.flightTarget() + ", expected=" + expected); - return TxResult.failed(TxResult.Reason.PLAYER_SHORT, leg.denars, 0); - } - } if (leg.from instanceof PlayerAccount player) { var character = player.character(); var captured = table.wagerCharacters().get(player.flightTarget()); @@ -220,6 +213,15 @@ public TxResult commit() { if (!leg.to.canAccept(plan.denars())) { return TxResult.failed(TxResult.Reason.BANK_SHORT, want, 0); } + // Only a leg that will actually pay someone needs its captured character checked; + // an empty tray or bucket plans nothing and must not freeze the table. + if (leg.to instanceof PlayerAccount recipient && recipient.flightTarget() != null) { + CharacterRef expected = table.wagerCharacters().get(recipient.flightTarget()); + if (expected == null || (recipient.online() && !expected.equals(recipient.character()))) { + WagerJournal.quarantine(table, "Payout character changed: owner=" + recipient.flightTarget() + ", expected=" + expected); + return TxResult.failed(TxResult.Reason.PLAYER_SHORT, plan.denars(), 0); + } + } if (leg.pool != null) { leg.pool[0] -= plan.denars(); } diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerInteractionTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerInteractionTest.java index d9bb50d..a9e080e 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerInteractionTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerInteractionTest.java @@ -211,6 +211,7 @@ private void hitTable(Table table, PlayerMock by) { assertNull(table.dealerId()); verify(game).onDealerGone(table); assertSame(table, manager.table(table.getId())); + assertFalse(table.moneyBlocked(), "returning an empty tray must not freeze the table"); } private Block surface(int x, int y, int z, double width, double height, double depth) { diff --git a/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java b/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java index 41e2820..2477d94 100644 --- a/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java +++ b/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java @@ -107,6 +107,52 @@ void bankAndMintFailuresIdentifyTemplateAndFundingProblems() { } } + private PlayerAccount payee(UUID owner, net.tfminecraft.tlibs.character.CharacterRef character) { + PlayerAccount payee = mock(PlayerAccount.class); + when(payee.flightTarget()).thenReturn(owner); + when(payee.online()).thenReturn(true); + when(payee.character()).thenReturn(character); + when(payee.canAccept(anyInt())).thenReturn(true); + when(payee.accept(anyList())).thenAnswer(call -> TableLedger.valueOf(call.getArgument(0))); + return payee; + } + + @Test + void emptyLegsToUncapturedPayeeMoveNothingAndDoNotQuarantine() { + UUID owner = UUID.randomUUID(); + PlayerAccount payee = payee(owner, + new net.tfminecraft.tlibs.character.CharacterRef(owner, UUID.randomUUID())); + // No plan stubbed: the tray/bucket is empty, so both legs plan nothing. + assertEquals(TxResult.Reason.NOTHING, tx().moveAll(source, payee).commit().reason()); + assertEquals(TxResult.Reason.NOTHING, tx().moveUpTo(source, payee, 10).commit().reason()); + assertFalse(table.moneyBlocked(), "a leg that moves no money must not freeze the table"); + } + + @Test + void fundedLegToUncapturedOrChangedPayeeStillQuarantines() { + UUID owner = UUID.randomUUID(); + var captured = new net.tfminecraft.tlibs.character.CharacterRef(owner, UUID.randomUUID()); + var other = new net.tfminecraft.tlibs.character.CharacterRef(owner, UUID.randomUUID()); + PlayerAccount payee = payee(owner, other); + plan(source, 5); + TxResult uncaptured = tx().move(source, payee, 5).commit(); + assertFalse(uncaptured.ok()); + assertEquals(TxResult.Reason.PLAYER_SHORT, uncaptured.reason()); + assertEquals(5, uncaptured.wanted()); + assertTrue(table.moneyBlocked(), "paying an uncaptured character freezes for staff"); + + Table changed = new Table(UUID.randomUUID(), "test", null, 0, null); + changed.wagerCharacters().put(owner, captured); + TxResult swapped = new MoneyTx(host, changed, "test").move(source, payee, 5).commit(); + assertFalse(swapped.ok()); + assertTrue(changed.moneyBlocked(), "paying a different character than captured freezes for staff"); + + Table matching = new Table(UUID.randomUUID(), "test", null, 0, null); + matching.wagerCharacters().put(owner, other); + assertEquals(5, new MoneyTx(host, matching, "test").move(source, payee, 5).commit().moved()); + assertFalse(matching.moneyBlocked()); + } + @Test void destinationRefusalDoesNotWithdraw() { Withdrawal plan = plan(source, 10); From c5b8f970a932a3be1de221f7c7dd2cb686cd74ab Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:26:51 +0000 Subject: [PATCH 5/5] fix: stop re-reading unloadable table files on deferred passes --- .../tfminecraft/games/table/TableManager.java | 30 +++++++++++++++++-- .../table/TableManagerPersistenceTest.java | 28 +++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index f6a2b51..e18785a 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -122,6 +122,8 @@ public final class TableManager implements Listener, WagerHost { private static final long INSPECT_TICKS = 5L; private final Map tables = new HashMap<>(); + /** Files that failed to parse, by name and content signature, so deferred passes skip them until fixed. */ + private final Map unloadableFiles = new HashMap<>(); /** Table id to owner to floating amount label, only used when chips are hidden. */ private final Map> bucketLabels = new HashMap<>(); private final Map arms = new HashMap<>(); @@ -285,6 +287,7 @@ private void loadTables(boolean onlyMissing) { for (File file : files) { try { if (onlyMissing) { + if (knownUnloadable(file)) continue; try { UUID fileId = UUID.fromString(file.getName().replace(".json", "")); if (tables.containsKey(fileId)) continue; @@ -292,22 +295,30 @@ private void loadTables(boolean onlyMissing) { } if (!net.tfminecraft.games.guild.GuildTableMigration.prepare(file.toPath(), new File(Games.plugin.getDataFolder(), "migrations/guild-tables").toPath())) continue; - } catch (IOException | RuntimeException ex) { + } catch (RuntimeException ex) { + // The file's shape is the problem; do not re-read it every pass until it changes. + markUnloadable(file); + Games.plugin.getLogger().warning("[Games] Failed to load table " + file.getName() + ": " + ex); + continue; + } catch (IOException ex) { Games.plugin.getLogger().warning("[Games] Failed to load table " + file.getName() + ": " + ex); continue; } try (FileReader reader = new FileReader(file, StandardCharsets.UTF_8)) { TableData data = GSON.fromJson(reader, TableData.class); if (data == null || data.id == null) { + markUnloadable(file); continue; } if (onlyMissing && tables.containsKey(playerId(data.id))) continue; List unowned = new ArrayList<>(); Table table = fromData(data, unowned); if (table == null) { + // A missing world or card set can arrive later, so this stays retryable. continue; } tables.put(table.getId(), table); + unloadableFiles.remove(file.getName()); if (net.tfminecraft.games.wager.WagerJournal.blocked(table)) { table.setMoneyBlocked(true); Games.plugin.getLogger().severe("[Games] Interrupted money operation freezes table " + table.getId()); @@ -333,7 +344,10 @@ private void loadTables(boolean onlyMissing) { Games.plugin.getLogger().warning("[Games] Failed to spawn loaded table " + file.getName() + ": " + ex.getMessage()); } - } catch (IOException | JsonParseException ex) { + } catch (JsonParseException ex) { + markUnloadable(file); + Games.plugin.getLogger().warning("[Games] Failed to load table " + file.getName() + ": " + ex.getMessage()); + } catch (IOException ex) { Games.plugin.getLogger().warning("[Games] Failed to load table " + file.getName() + ": " + ex.getMessage()); } } @@ -342,6 +356,18 @@ private void loadTables(boolean onlyMissing) { } } + private static String fileSignature(File file) { + return file.lastModified() + ":" + file.length(); + } + + private boolean knownUnloadable(File file) { + return fileSignature(file).equals(unloadableFiles.get(file.getName())); + } + + private void markUnloadable(File file) { + unloadableFiles.put(file.getName(), fileSignature(file)); + } + public void despawnWorldAll() { for (Table table : tables.values()) { resetTableToIdle(table, table.getOrigin()); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java index 4dd5b3c..e3d8c75 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java @@ -639,6 +639,34 @@ void placingATableRecordsWhetherItUsedADeck(boolean requireDeck) throws Exceptio assertEquals(0, inventoryGold()); } + @Test void deferredPassesSkipUnchangedUnloadableFilesUntilTheyAreFixed() throws Exception { + Files.createDirectories(folder()); + Files.writeString(folder().resolve("broken.json"), "{\"id\":"); + JsonObject invalid = document(UUID.randomUUID()); + invalid.addProperty("id", "not-a-uuid"); + write("invalid-id.json", invalid); + manager.loadDeferred(); + manager.loadDeferred(); + verify(Games.plugin.getLogger(), times(1)).warning(contains("Failed to load table broken.json")); + verify(Games.plugin.getLogger(), times(1)).warning(contains("invalid-id.json")); + assertTrue(manager.tables().isEmpty()); + UUID fixed = UUID.randomUUID(); + write("broken.json", document(fixed)); + manager.loadDeferred(); + assertNotNull(manager.table(fixed), "a repaired file is picked up on the next deferred pass"); + } + + @Test void deferredPassesKeepRetryingTablesWhoseWorldIsMissing() throws Exception { + UUID id = UUID.randomUUID(); + JsonObject saved = document(id); + saved.addProperty("world", "unloaded-world"); + write(id + ".json", saved); + manager.loadDeferred(); + manager.loadDeferred(); + verify(Games.plugin.getLogger(), times(2)).warning(contains("Table world missing: unloaded-world")); + assertNull(manager.table(id)); + } + @Test void deferredLoadingDoesNotReplaceAnActiveTableStoredUnderAnImportedFilename() throws Exception { UUID id = UUID.randomUUID(); write("imported.json", document(id));