diff --git a/README.md b/README.md index 463c44d..f18bd71 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ It supports both structured games with managed rounds and a free-play table for - **Poker tournaments** — host buy-in Hold'em tournaments with chip stacks, rebuys, antes and rising blinds. - **Blackjack against the house** — play with an automatic dealer or a player taking the dealer's position, including double and split decisions. - **Guild-owned tables** — connect house games to a guild's funds and let its members take over dealing between rounds. +- **Character-owned private tables** — private owner controls follow the recorded character, with guarded property inheritance after a settled game. - **Help at the table** — in-game books explain each game's rules and available actions. ## Documentation @@ -35,6 +36,9 @@ Surefire writes test results to `target/surefire-reports/`. JaCoCo writes HTML, XML and CSV reports to `target/site/jacoco/`, and CI uploads them as a `coverage-reports-*` artifact. No coverage threshold is enforced. Packet encoding and client rendering still need a run on a real server. +Ownership tests cover raw migration preservation, current character sessions, +stale menus and idle-only estate recovery. The standalone compatibility probe +and conservative rollback procedure are described in the project documentation. ## License diff --git a/integration-tests/LegacyApiCheck.java b/integration-tests/LegacyApiCheck.java new file mode 100644 index 0000000..2efc5fd --- /dev/null +++ b/integration-tests/LegacyApiCheck.java @@ -0,0 +1,71 @@ +import org.mockbukkit.mockbukkit.MockBukkit; + +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Load actual older TLibs jars independently of the current test dependency. */ +public final class LegacyApiCheck { + public static void main(String[] args) throws Exception { + var server = MockBukkit.mock(); + try (var loader = + new URLClassLoader( + new java.net.URL[] { + Path.of(args[0]).toUri().toURL(), Path.of(args[1]).toUri().toURL() + }, + LegacyApiCheck.class.getClassLoader()) { + @Override + protected Class loadClass(String name, boolean resolve) + throws ClassNotFoundException { + if (name.startsWith("net.tfminecraft.games.") + || name.startsWith("net.tfminecraft.tlibs.")) { + Class type = findLoadedClass(name); + if (type == null) type = findClass(name); + if (resolve) resolveClass(type); + return type; + } + return super.loadClass(name, resolve); + } + }) { + Class identity = loader.loadClass("net.tfminecraft.games.ownership.CharacterAccess"); + if (!Boolean.valueOf(args[2]).equals(identity.getMethod("available").invoke(null))) + throw new AssertionError("Incorrect older character API capability"); + if (!Boolean.FALSE.equals(identity.getMethod("heirReady").invoke(null))) + throw new AssertionError("Missing reviewed provider must fail closed"); + if (identity.getMethod("capture", org.bukkit.entity.Player.class) + .invoke(null, server.addPlayer()) + != null) + throw new AssertionError( + "Missing current identity must not capture owner authority"); + Class estates = loader.loadClass("net.tfminecraft.games.ownership.EstateBridge"); + if (!Boolean.FALSE.equals(estates.getMethod("available").invoke(null))) + throw new AssertionError("Older TLibs must not accept guarded estates"); + Path directory = Path.of(args[3]); + Files.createDirectories(directory); + Path source = directory.resolve("legacy.json"); + String original = + "{\"id\":\"33333333-3333-3333-3333-333333333333\",\"ownerPlayer\":\"11111111-1111-1111-1111-111111111111\",\"staffMint\":false,\"unknown\":null}"; + Files.writeString(source, original); + Class migration = + loader.loadClass("net.tfminecraft.games.ownership.OwnershipMigration"); + Object reader = + migration + .getConstructor(Path.class) + .newInstance(directory.resolve("migrations")); + if (!Boolean.FALSE.equals( + migration.getMethod("prepare", Path.class).invoke(reader, source)) + || !original.equals(Files.readString(source)) + || Files.exists(directory.resolve("migrations"))) + throw new AssertionError( + "Unavailable migration must preserve legacy private source exactly"); + System.out.println( + "{\"passed\":true,\"checks\":5,\"legacyJar\":\"" + + args[1] + + "\",\"characterApi\":" + + args[2] + + "}"); + } finally { + MockBukkit.unmock(); + } + } +} diff --git a/pom.xml b/pom.xml index d622583..3ac210a 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ 0.3.0 2.16.0 4.0.0 - 2.4.0 + 2.6.0 diff --git a/src/main/java/net/tfminecraft/games/Games.java b/src/main/java/net/tfminecraft/games/Games.java index 34dfa05..e3bbfdd 100644 --- a/src/main/java/net/tfminecraft/games/Games.java +++ b/src/main/java/net/tfminecraft/games/Games.java @@ -33,12 +33,20 @@ public class Games extends JavaPlugin { private final HelpLoader helpLoader = new HelpLoader(); private final CommandManager commandManager = new CommandManager(); private final WagerCommand wagerCommand = new WagerCommand(); + private boolean ownershipActive; @Override public void onEnable() { plugin = this; net.tfminecraft.games.wager.WagerJournal.initialize(); createFolders(); + try { + net.tfminecraft.games.persistence.DurableFiles.write(getDataFolder().toPath().resolve("ownership-active"),"Games is running\n"); + ownershipActive=true; + } catch(IOException ex) { + getLogger().severe("Could not establish the ownership recovery marker: "+ex.getMessage()); + getServer().getPluginManager().disablePlugin(this);return; + } createConfigs(); if (!loadConfigs()) { getLogger().warning("Games loaded with config errors."); @@ -49,7 +57,9 @@ public void onEnable() { getServer().getPluginManager().registerEvents(TableManager.get(), this); getServer().getPluginManager().registerEvents(GameSelectGui.INSTANCE, this); getServer().getPluginManager().registerEvents(TableOptionsGui.INSTANCE, this); + net.tfminecraft.games.ownership.EstateBridge.start(this,TableManager.get()); TableManager.get().loadAll(); + net.tfminecraft.games.ownership.CharacterAccess.start(this); getServer().getScheduler().runTaskTimer(this, () -> TableManager.get().loadDeferred(), 100L, 100L); TableManager.get().startClock(); @@ -77,6 +87,11 @@ public void onDisable() { TableManager.get().despawnWorldAll(); DisplayManager.get().shutdown(); ProtocolLibBridge.shutdown(); + if(ownershipActive && TableManager.get().tables().stream().noneMatch(table->table.ownershipHeld() || table.moneyBlocked() + || net.tfminecraft.games.wager.WagerJournal.blocked(table))) { + try { net.tfminecraft.games.persistence.DurableFiles.delete(getDataFolder().toPath().resolve("ownership-active"));ownershipActive=false; } + catch(IOException ex) { getLogger().warning("Ownership recovery marker retained: "+ex.getMessage()); } + } getLogger().info("Games disabled."); } diff --git a/src/main/java/net/tfminecraft/games/gui/GameSelectGui.java b/src/main/java/net/tfminecraft/games/gui/GameSelectGui.java index 3946a6d..c760606 100644 --- a/src/main/java/net/tfminecraft/games/gui/GameSelectGui.java +++ b/src/main/java/net/tfminecraft/games/gui/GameSelectGui.java @@ -34,7 +34,9 @@ private GameSelectGui() {} // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public static void open(Player player, boolean requireDeck, Location pending) { - GameSelectHolder holder = new GameSelectHolder(requireDeck, pending); + var token=net.tfminecraft.games.ownership.CharacterAccess.capture(player); + if(token==null) return; + GameSelectHolder holder = new GameSelectHolder(requireDeck, pending,token); Inventory inventory = Bukkit.createInventory(holder, SIZE, Messages.get("place.gui_title")); holder.setInventory(inventory); inventory.setItem(POKER_SLOT, gameIcon("poker", "place.gui_poker")); @@ -70,6 +72,7 @@ public void onClick(InventoryClickEvent event) { event.setCancelled(true); // Only players can open this menu. Player player = (Player) event.getWhoClicked(); + if(!net.tfminecraft.games.ownership.CharacterAccess.current(holder.session())) { player.closeInventory();return; } if (event.getClickedInventory() == null || event.getClickedInventory() != event.getView().getTopInventory()) { return; } diff --git a/src/main/java/net/tfminecraft/games/gui/GameSelectHolder.java b/src/main/java/net/tfminecraft/games/gui/GameSelectHolder.java index b156b47..394dfca 100644 --- a/src/main/java/net/tfminecraft/games/gui/GameSelectHolder.java +++ b/src/main/java/net/tfminecraft/games/gui/GameSelectHolder.java @@ -11,12 +11,18 @@ public final class GameSelectHolder implements InventoryHolder { private final boolean requireDeck; private final Location pendingHit; + private final net.tfminecraft.games.ownership.OwnerSession session; private Inventory inventory; public GameSelectHolder(boolean requireDeck, Location pendingHit) { + this(requireDeck,pendingHit,null); + } + public GameSelectHolder(boolean requireDeck, Location pendingHit, net.tfminecraft.games.ownership.OwnerSession session) { this.requireDeck = requireDeck; this.pendingHit = pendingHit != null ? pendingHit.clone() : null; + this.session = session; } + public net.tfminecraft.games.ownership.OwnerSession session() { return session; } public boolean requireDeck() { return requireDeck; diff --git a/src/main/java/net/tfminecraft/games/gui/TableOptionsGui.java b/src/main/java/net/tfminecraft/games/gui/TableOptionsGui.java index 249c892..a5e2674 100644 --- a/src/main/java/net/tfminecraft/games/gui/TableOptionsGui.java +++ b/src/main/java/net/tfminecraft/games/gui/TableOptionsGui.java @@ -48,6 +48,7 @@ public final class TableOptionsGui implements Listener { private static final long CHAT_TICKS = 20L * 60; private static final Map prompts = new ConcurrentHashMap<>(); + public static void clear(UUID account) { prompts.remove(account); } private TableOptionsGui() {} @@ -71,8 +72,10 @@ public static void openEdit(Player player, Table table) { @SuppressWarnings("deprecation") private static void open(Player player, boolean requireDeck, Location pending, UUID editId, String gameId, TableHouse house) { + var token=net.tfminecraft.games.ownership.CharacterAccess.capture(player); + if(token==null) return; prompts.remove(player.getUniqueId()); - TableOptionsHolder holder = new TableOptionsHolder(requireDeck, pending, editId, gameId, house); + TableOptionsHolder holder = new TableOptionsHolder(requireDeck, pending, editId, gameId, house,token); Inventory inventory = Bukkit.createInventory(holder, SIZE, Messages.get("place.options_title")); holder.setInventory(inventory); fill(player, holder); @@ -168,6 +171,7 @@ public void onClick(InventoryClickEvent event) { event.setCancelled(true); // Only players can open this menu. Player player = (Player) event.getWhoClicked(); + if(!net.tfminecraft.games.ownership.CharacterAccess.current(holder.session())) { player.closeInventory();return; } if (event.getClickedInventory() == null || event.getClickedInventory() != event.getView().getTopInventory()) { return; } @@ -220,7 +224,7 @@ private static void startChat(Player player, TableOptionsHolder holder, ChatFiel GuiSounds.click(player); UUID id = player.getUniqueId(); ChatPrompt prompt = new ChatPrompt(holder.requireDeck(), holder.pendingHit(), holder.editTableId(), - holder.gameId(), holder.house(), field); + holder.gameId(), holder.house(), field,holder.session()); prompts.put(id, prompt); player.closeInventory(); player.sendMessage(Messages.get("place.options_chat_" + field.key)); @@ -231,7 +235,7 @@ private static void startChat(Player player, TableOptionsHolder holder, ChatFiel } // Quitting clears the prompt, so a prompt that is still waiting belongs to an online player. prompts.remove(id); - Bukkit.getPlayer(id).sendMessage(Messages.get("place.options_chat_timeout")); + if(net.tfminecraft.games.ownership.CharacterAccess.current(prompt.session)) Bukkit.getPlayer(id).sendMessage(Messages.get("place.options_chat_timeout")); }, CHAT_TICKS); } @@ -256,10 +260,14 @@ public void onQuit(PlayerQuitEvent event) { private static void applyChat(Player player, ChatPrompt prompt, String raw) { ChatPrompt current = prompts.get(player.getUniqueId()); - if (current != prompt) { + if (current != prompt || !net.tfminecraft.games.ownership.CharacterAccess.current(prompt.session)) { return; } prompts.remove(player.getUniqueId()); + if(prompt.editId!=null) { + Table table=TableManager.get().table(prompt.editId); + if(table==null || !prompt.house.matchesOwner(table)) return; + } if (raw.equalsIgnoreCase("cancel")) { player.sendMessage(Messages.get("place.options_chat_cancelled")); reopen(player, prompt); @@ -306,6 +314,7 @@ private static void reopen(Player player, ChatPrompt prompt) { } private static void confirm(Player player, TableOptionsHolder holder) { + if(!net.tfminecraft.games.ownership.CharacterAccess.current(holder.session())) return; TableHouse house = holder.house(); boolean poker = isPoker(holder.gameId()); if (!poker) { @@ -333,11 +342,13 @@ private static void confirm(Player player, TableOptionsHolder holder) { player.sendMessage(Messages.get("place.options_denied")); return; } + if(!house.matchesOwner(table)) return; TableManager.get().applyHouse(table, house); player.sendMessage(Messages.get("place.options_saved")); return; } - house.setOwnerPlayer(player.getUniqueId()); + house.setOwnerPlayer(holder.session().account()); + house.setOwnerCharacterId(holder.session().character()); TableManager.get().armPlace(player, holder.gameId(), holder.requireDeck(), house); Location at = holder.pendingHit(); if (at != null) { @@ -440,15 +451,17 @@ private static final class ChatPrompt { private final String gameId; private final TableHouse house; private final ChatField field; + private final net.tfminecraft.games.ownership.OwnerSession session; private ChatPrompt(boolean requireDeck, Location pending, UUID editId, String gameId, TableHouse house, - ChatField field) { + ChatField field,net.tfminecraft.games.ownership.OwnerSession session) { this.requireDeck = requireDeck; this.pending = pending; this.editId = editId; this.gameId = gameId; this.house = house; this.field = field; + this.session = session; } } } diff --git a/src/main/java/net/tfminecraft/games/gui/TableOptionsHolder.java b/src/main/java/net/tfminecraft/games/gui/TableOptionsHolder.java index 014d9e3..beb5499 100644 --- a/src/main/java/net/tfminecraft/games/gui/TableOptionsHolder.java +++ b/src/main/java/net/tfminecraft/games/gui/TableOptionsHolder.java @@ -15,16 +15,23 @@ public final class TableOptionsHolder implements InventoryHolder { private final UUID editTableId; private final String gameId; private final TableHouse house; + private final net.tfminecraft.games.ownership.OwnerSession session; private Inventory inventory; public TableOptionsHolder(boolean requireDeck, Location pendingHit, UUID editTableId, String gameId, TableHouse house) { + this(requireDeck,pendingHit,editTableId,gameId,house,null); + } + public TableOptionsHolder(boolean requireDeck, Location pendingHit, UUID editTableId, String gameId, + TableHouse house, net.tfminecraft.games.ownership.OwnerSession session) { this.requireDeck = requireDeck; this.pendingHit = pendingHit != null ? pendingHit.clone() : null; this.editTableId = editTableId; this.gameId = gameId; this.house = house; + this.session = session; } + public net.tfminecraft.games.ownership.OwnerSession session() { return session; } public boolean requireDeck() { return requireDeck; diff --git a/src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java b/src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java index 16c5cd2..8d541eb 100644 --- a/src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java +++ b/src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java @@ -13,8 +13,9 @@ 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; + JsonObject data = net.tfminecraft.games.ownership.RawJson.read(tableFile); + if (data.has("ownerGuildMigrated") && !data.get("ownerGuildMigrated").isJsonNull() + && 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; @@ -23,7 +24,9 @@ public static boolean prepare(Path tableFile, Path migrationFolder) throws IOExc 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(); + migrated = net.tfminecraft.games.ownership.RawJson.read(manifest); + JsonObject original = net.tfminecraft.games.ownership.RawJson.read(backup); + if (!data.equals(original) && !data.equals(migrated)) throw new IOException("Table changed after frozen guild planning"); } 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. diff --git a/src/main/java/net/tfminecraft/games/guild/GuildTables.java b/src/main/java/net/tfminecraft/games/guild/GuildTables.java index 42bdd44..19fa6da 100644 --- a/src/main/java/net/tfminecraft/games/guild/GuildTables.java +++ b/src/main/java/net/tfminecraft/games/guild/GuildTables.java @@ -189,6 +189,7 @@ public static boolean mayDeal(Table table, Player player) { if (table == null || player == null) { return false; } + if (table.ownershipHeld() || table.estatePending()) return false; if (player.hasPermission(TableHouse.STAFF_PERM) || player.hasPermission("games.admin")) { return true; } @@ -196,7 +197,8 @@ public static boolean mayDeal(Table table, Player player) { if (guildId != null && !guildId.isBlank()) { return owns(guildId, table.ownerGuildIncarnation()) && isMember(guildId, player); } - return table.ownerPlayer() != null && table.ownerPlayer().equals(player.getUniqueId()); + return net.tfminecraft.games.ownership.OwnershipKind.of(table) == net.tfminecraft.games.ownership.OwnershipKind.PRIVATE + && !table.estatePending() && net.tfminecraft.games.ownership.CharacterAccess.owns(table, player); } /** True for the leader of the guild that owns this table, who is the one who may place and remove it. */ diff --git a/src/main/java/net/tfminecraft/games/ownership/CharacterAccess.java b/src/main/java/net/tfminecraft/games/ownership/CharacterAccess.java new file mode 100644 index 0000000..4bc4aa5 --- /dev/null +++ b/src/main/java/net/tfminecraft/games/ownership/CharacterAccess.java @@ -0,0 +1,73 @@ +package net.tfminecraft.games.ownership; + +import net.tfminecraft.games.table.Table; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import java.util.Optional; +import java.util.UUID; + +/** Capability probe precedes all links to newer TLibs classes. */ +public final class CharacterAccess { + private static final java.util.Map published = + new java.util.concurrent.ConcurrentHashMap<>(); + + private CharacterAccess() {} + + public static boolean available() { + try { + Class api = Class.forName("net.tfminecraft.tlibs.character.Characters"); + api.getMethod("legacyHeirsReady"); + api.getMethod("legacyHeir", UUID.class); + Class.forName("net.tfminecraft.tlibs.character.CharacterSessionEndEvent"); + return true; + } catch (ReflectiveOperationException | LinkageError ex) { + return false; + } + } + + public static OwnerSession capture(Player player) { + if (player == null || !available()) return null; + OwnerSession token = TLibsCharacters.capture(player); + if (token == null) published.remove(player.getUniqueId()); + else published.put(player.getUniqueId(), token); + return token; + } + + public static OwnerSession published(UUID account) { + return published.get(account); + } + + public static void ended(UUID account) { + published.remove(account); + } + + public static void start(org.bukkit.plugin.Plugin plugin) { + if (available()) TLibsCharacters.start(plugin); + } + + public static boolean current(OwnerSession token) { + return token != null && token.equals(capture(Bukkit.getPlayer(token.account()))); + } + + public static boolean owns(Table table, Player player) { + OwnerSession token = capture(player); + return token != null + && !table.ownershipHeld() + && token.account().equals(table.ownerPlayer()) + && token.character().equals(table.ownerCharacterId()); + } + + public static boolean heirReady() { + return available() && TLibsCharacters.heirReady(); + } + + public static boolean deals(Table table, Player player) { + return available() && TLibsCharacters.deals(table, player); + } + + public static Optional heir(UUID account) { + return available() ? TLibsCharacters.heir(account) : Optional.empty(); + } +} diff --git a/src/main/java/net/tfminecraft/games/ownership/CharacterLifecycle.java b/src/main/java/net/tfminecraft/games/ownership/CharacterLifecycle.java new file mode 100644 index 0000000..861c68c --- /dev/null +++ b/src/main/java/net/tfminecraft/games/ownership/CharacterLifecycle.java @@ -0,0 +1,40 @@ +package net.tfminecraft.games.ownership; + +import net.tfminecraft.games.gui.GameSelectHolder; +import net.tfminecraft.games.gui.TableOptionsGui; +import net.tfminecraft.games.gui.TableOptionsHolder; +import net.tfminecraft.games.table.TableManager; +import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; +import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; + +import org.bukkit.Bukkit; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +final class CharacterLifecycle implements Listener { + @EventHandler(priority = EventPriority.MONITOR) + public void end(CharacterSessionEndEvent event) { + var account = event.getSession().ref().ownerId(); + if (!account.equals(event.getPlayer().getUniqueId())) return; + CharacterAccess.ended(account); + TableOptionsGui.clear(account); + var player = event.getPlayer(); + if (Bukkit.getPlayer(account) == player) { + net.tfminecraft.games.wager.OutgoingRefund.during(player, event.getSession(), + () -> TableManager.get().clearPlayer(player)); + var holder = player.getOpenInventory().getTopInventory().getHolder(); + if (holder instanceof TableOptionsHolder || holder instanceof GameSelectHolder) + player.closeInventory(); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void start(CharacterSessionStartEvent event) { + var account = event.getSession().ref().ownerId(); + if (!account.equals(event.getPlayer().getUniqueId())) return; + TableOptionsGui.clear(account); + TableManager.get().clearPlacement(account); + CharacterAccess.capture(Bukkit.getPlayer(account)); + } +} diff --git a/src/main/java/net/tfminecraft/games/ownership/EstateBridge.java b/src/main/java/net/tfminecraft/games/ownership/EstateBridge.java new file mode 100644 index 0000000..05f8338 --- /dev/null +++ b/src/main/java/net/tfminecraft/games/ownership/EstateBridge.java @@ -0,0 +1,26 @@ +package net.tfminecraft.games.ownership; + +import net.tfminecraft.games.table.TableManager; + +import org.bukkit.plugin.Plugin; + +public final class EstateBridge { + private EstateBridge() {} + + public static boolean available() { + try { + Class.forName("net.tfminecraft.tlibs.character.estate.EstateParticipant"); + Class estates = Class.forName("net.tfminecraft.tlibs.character.estate.Estates"); + estates.getMethod( + "guardHolds", + Class.forName("net.tfminecraft.tlibs.character.estate.EstateDecision")); + return true; + } catch (ReflectiveOperationException | LinkageError ex) { + return false; + } + } + + public static void start(Plugin plugin, TableManager manager) { + if (available()) TableEstate.start(plugin, manager); + } +} diff --git a/src/main/java/net/tfminecraft/games/ownership/OwnerSession.java b/src/main/java/net/tfminecraft/games/ownership/OwnerSession.java new file mode 100644 index 0000000..72a465b --- /dev/null +++ b/src/main/java/net/tfminecraft/games/ownership/OwnerSession.java @@ -0,0 +1,6 @@ +package net.tfminecraft.games.ownership; + +import java.util.UUID; + +/** Local token keeps optional character API types outside ordinary table classes. */ +public record OwnerSession(UUID account, UUID character, long generation) {} diff --git a/src/main/java/net/tfminecraft/games/ownership/OwnershipKind.java b/src/main/java/net/tfminecraft/games/ownership/OwnershipKind.java new file mode 100644 index 0000000..d9d8079 --- /dev/null +++ b/src/main/java/net/tfminecraft/games/ownership/OwnershipKind.java @@ -0,0 +1,34 @@ +package net.tfminecraft.games.ownership; + +import com.google.gson.JsonObject; + +import net.tfminecraft.games.table.Table; + +/** Funding metadata is explicit; guild association survives staff mint and human takeover. */ +public enum OwnershipKind { + PRIVATE, + GUILD, + STAFF; + + public static OwnershipKind of(JsonObject row) { + String guild = RawJson.string(row, "ownerGuildId"); + if (guild != null && !guild.isBlank()) return GUILD; + return effectiveMint(row) ? STAFF : PRIVATE; + } + + public static boolean modeRecorded(JsonObject row) { + return row.has("staffMint") && !row.get("staffMint").isJsonNull(); + } + + public static boolean effectiveMint(JsonObject row) { + if (modeRecorded(row)) return row.get("staffMint").getAsBoolean(); + if (row.has("autoDealer") && !row.get("autoDealer").isJsonNull()) return false; + var layout = net.tfminecraft.games.cache.Cache.layoutOf(RawJson.string(row, "gameId")); + return layout != null && layout.autoDealer(); + } + + public static OwnershipKind of(Table table) { + if (table.ownerGuildId() != null && !table.ownerGuildId().isBlank()) return GUILD; + return table.staffMint() ? STAFF : PRIVATE; + } +} diff --git a/src/main/java/net/tfminecraft/games/ownership/OwnershipMigration.java b/src/main/java/net/tfminecraft/games/ownership/OwnershipMigration.java new file mode 100644 index 0000000..0ab9d57 --- /dev/null +++ b/src/main/java/net/tfminecraft/games/ownership/OwnershipMigration.java @@ -0,0 +1,236 @@ +package net.tfminecraft.games.ownership; + +import com.google.gson.JsonObject; + +import net.tfminecraft.games.persistence.DurableFiles; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Optional; +import java.util.UUID; +import java.util.function.BooleanSupplier; +import java.util.function.Function; + +/** Per-file expand migration: archive and frozen intent precede owner-envelope publication. */ +public final class OwnershipMigration { + private final Path journals; + private final BooleanSupplier ready; + private final Function> heir; + + public OwnershipMigration(Path journals) { + this(journals, CharacterAccess::heirReady, CharacterAccess::heir); + } + + public OwnershipMigration( + Path journals, BooleanSupplier ready, Function> heir) { + this.journals = journals; + this.ready = ready; + this.heir = heir; + } + + public boolean prepare(Path source) throws IOException { + Path folder = + journals.resolve( + hash( + source.getFileName() + .toString() + .getBytes(java.nio.charset.StandardCharsets.UTF_8))); + if (Files.isDirectory(folder)) { + try (var files = Files.list(folder)) { + for (Path file : files.filter(path -> path.toString().endsWith(".json")).toList()) { + JsonObject plan = RawJson.read(file); + validate(plan, source, file); + if (!plan.get("complete").getAsBoolean()) publish(source, file, plan); + } + } + try (var files = Files.list(folder)) { + for (Path archive : + files.filter(path -> path.toString().endsWith(".original")).toList()) { + String name = archive.getFileName().toString().replace(".original", ""); + if (!Files.exists(archive.resolveSibling(name + ".json")) + && !hash(Files.readAllBytes(source)).equals(name)) + throw new IOException("Orphan ownership archive needs review"); + } + } + } + byte[] before = Files.readAllBytes(source); + JsonObject raw = RawJson.parse(new String(before, java.nio.charset.StandardCharsets.UTF_8)); + validateRow(raw); + UUID account = RawJson.uuid(raw.get("ownerPlayer")); + UUID character = RawJson.uuid(raw.get("ownerCharacterId")); + boolean needsOwner = account != null && character == null, + mode = !OwnershipKind.modeRecorded(raw); + boolean ownerChange = false; + Optional reviewed = Optional.empty(); + if (needsOwner) { + if (!ready.getAsBoolean()) { + if (OwnershipKind.of(raw) == OwnershipKind.PRIVATE) return false; + } else { + reviewed = heir.apply(account); + ownerChange = + reviewed.isPresent() + || !raw.has("ownerIdentityVersion") + || RawJson.integer(raw.get("ownerIdentityVersion")) != 2; + } + } + if (!ownerChange && !mode) return true; + JsonObject next = raw.deepCopy(); + if (ownerChange) { + next.addProperty("ownerIdentityVersion", 2); + if (reviewed.isPresent()) + next.addProperty("ownerCharacterId", reviewed.get().toString()); + else next.add("ownerCharacterId", com.google.gson.JsonNull.INSTANCE); + } + if (mode) next.addProperty("staffMint", OwnershipKind.effectiveMint(raw)); + String beforeHash = hash(before), output = RawJson.JSON.toJson(next); + Path archive = folder.resolve(beforeHash + ".original"); + Path file = folder.resolve(beforeHash + ".json"); + if (Files.exists(archive) && !hash(Files.readAllBytes(archive)).equals(beforeHash)) + throw new IOException("Ownership archive conflict"); + if (!Files.exists(archive)) DurableFiles.write(archive, before); + JsonObject plan = new JsonObject(); + plan.addProperty("source", source.getFileName().toString()); + plan.addProperty("before", beforeHash); + plan.addProperty("after", hash(output.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + if (account == null) plan.add("account", com.google.gson.JsonNull.INSTANCE); + else plan.addProperty("account", account.toString()); + plan.add( + "character", + next.has("ownerCharacterId") + ? next.get("ownerCharacterId") + : com.google.gson.JsonNull.INSTANCE); + plan.addProperty("ownerChange", ownerChange); + if (mode) plan.add("staffMint", next.get("staffMint")); + plan.add("planned", next); + plan.addProperty("complete", false); + DurableFiles.write(file, RawJson.JSON.toJson(plan)); + publish(source, file, plan); + return true; + } + + private static void validate(JsonObject plan, Path source, Path file) throws IOException { + String before = RawJson.string(plan, "before"), after = RawJson.string(plan, "after"); + if (before == null + || !before.matches("[a-f0-9]{64}") + || after == null + || !after.matches("[a-f0-9]{64}") + || !source.getFileName().toString().equals(RawJson.string(plan, "source")) + || !file.getFileName().toString().equals(before + ".json") + || !plan.has("complete") + || !plan.get("complete").isJsonPrimitive() + || !plan.get("complete").getAsJsonPrimitive().isBoolean() + || !plan.has("planned") + || !plan.get("planned").isJsonObject()) + throw new IOException("Invalid ownership intent envelope"); + Path archive = file.resolveSibling(before + ".original"); + byte[] bytes = Files.readAllBytes(archive); + if (!before.equals(hash(bytes))) throw new IOException("Ownership archive changed"); + JsonObject original = + RawJson.parse(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)); + validateRow(original); + UUID account = RawJson.uuid(plan.get("account")), + character = RawJson.uuid(plan.get("character")); + boolean ownerChange = !plan.has("ownerChange") || booleanValue(plan, "ownerChange"); + if (ownerChange && RawJson.uuid(original.get("ownerCharacterId")) != null) + throw new IOException("Recorded character cannot be recaptured"); + if (!plan.has("account") + || (!plan.get("account").isJsonNull() && account == null) + || !java.util.Objects.equals(account, RawJson.uuid(original.get("ownerPlayer"))) + || (ownerChange && account == null) + || (plan.get("character") == null + || (!plan.get("character").isJsonNull() && character == null))) + throw new IOException("Invalid frozen owner mapping"); + JsonObject expected = original.deepCopy(); + if (ownerChange) { + expected.addProperty("ownerIdentityVersion", 2); + expected.add("ownerCharacterId", plan.get("character")); + } else if (!java.util.Objects.equals( + character, RawJson.uuid(original.get("ownerCharacterId")))) + throw new IOException("Mode-only plan changed its owner"); + if (plan.has("staffMint")) { + if (OwnershipKind.modeRecorded(original)) + throw new IOException("Mode plan overwrote explicit funding mode"); + expected.addProperty("staffMint", booleanValue(plan, "staffMint")); + } else if (!ownerChange) throw new IOException("Empty table migration plan"); + if (!expected.equals(plan.getAsJsonObject("planned")) + || !after.equals( + hash( + RawJson.JSON + .toJson(expected) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)))) + throw new IOException("Frozen ownership output changed"); + } + + private static boolean booleanValue(JsonObject plan, String key) throws IOException { + if (!plan.has(key) + || !plan.get(key).isJsonPrimitive() + || !plan.get(key).getAsJsonPrimitive().isBoolean()) + throw new IOException("Invalid frozen " + key); + return plan.get(key).getAsBoolean(); + } + + private static void publish(Path source, Path file, JsonObject plan) throws IOException { + validate(plan, source, file); + String current = hash(Files.readAllBytes(source)); + if (current.equals(plan.get("before").getAsString())) + DurableFiles.write(source, RawJson.JSON.toJson(plan.get("planned"))); + else if (!current.equals(plan.get("after").getAsString())) + throw new IOException("Table changed after frozen ownership planning"); + plan.addProperty("complete", true); + DurableFiles.write(file, RawJson.JSON.toJson(plan)); + } + + public static void validateRow(JsonObject raw) throws IOException { + if (raw.has("ownerIdentityVersion")) { + long version = RawJson.integer(raw.get("ownerIdentityVersion")); + if (version != 1 && version != 2) + throw new IOException("Unsupported owner identity version"); + } + for (String key : + java.util.List.of( + "ownerPlayer", "ownerCharacterId", "ownerGuildIncarnation", "floatOwner")) { + if (raw.has(key) && !raw.get(key).isJsonNull() && RawJson.uuid(raw.get(key)) == null) + throw new IOException("Invalid " + key); + } + if (RawJson.uuid(raw.get("ownerCharacterId")) != null + && RawJson.uuid(raw.get("ownerPlayer")) == null) + throw new IOException("Character owner has no account"); + if (RawJson.uuid(raw.get("ownerCharacterId")) != null + && (!raw.has("ownerIdentityVersion") + || RawJson.integer(raw.get("ownerIdentityVersion")) != 2)) + throw new IOException("Character owner lacks schema 2"); + if (raw.has("ownerGuildId") + && !raw.get("ownerGuildId").isJsonNull() + && RawJson.string(raw, "ownerGuildId") == null) + throw new IOException("Invalid guild association"); + if (RawJson.uuid(raw.get("ownerGuildIncarnation")) != null + && (RawJson.string(raw, "ownerGuildId") == null + || RawJson.string(raw, "ownerGuildId").isBlank())) + throw new IOException("Guild incarnation lacks its guild association"); + for (String key : + java.util.List.of( + "staffMint", + "autoDealer", + "ownerGuildMigrated", + "ownershipLive", + "ownershipPaying")) { + if (raw.has(key) + && !raw.get(key).isJsonNull() + && (!raw.get(key).isJsonPrimitive() + || !raw.get(key).getAsJsonPrimitive().isBoolean())) + throw new IOException("Invalid " + key); + } + } + + public static String hash(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException(ex); + } + } +} diff --git a/src/main/java/net/tfminecraft/games/ownership/RawJson.java b/src/main/java/net/tfminecraft/games/ownership/RawJson.java new file mode 100644 index 0000000..96bf884 --- /dev/null +++ b/src/main/java/net/tfminecraft/games/ownership/RawJson.java @@ -0,0 +1,111 @@ +package net.tfminecraft.games.ownership; + +import com.google.gson.*; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; + +import java.io.IOException; +import java.io.StringReader; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +/** Reject duplicate keys before a parser or typed model can discard them. */ +public final class RawJson { + public static final Gson JSON = new GsonBuilder().serializeNulls().setPrettyPrinting().create(); + + private RawJson() {} + + public static JsonObject read(Path path) throws IOException { + return parse(Files.readString(path)); + } + + public static JsonObject parse(String text) throws IOException { + try (JsonReader reader = new JsonReader(new StringReader(text))) { + reader.setStrictness(Strictness.STRICT); + JsonElement value = value(reader); + if (!value.isJsonObject() || reader.peek() != JsonToken.END_DOCUMENT) + throw new IOException("Expected one JSON object"); + return value.getAsJsonObject(); + } catch (RuntimeException ex) { + throw new IOException("Invalid JSON", ex); + } + } + + private static JsonElement value(JsonReader reader) throws IOException { + return switch (reader.peek()) { + case BEGIN_OBJECT -> { + JsonObject object = new JsonObject(); + reader.beginObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + if (object.has(name)) throw new IOException("Duplicate JSON key: " + name); + object.add(name, value(reader)); + } + reader.endObject(); + yield object; + } + case BEGIN_ARRAY -> { + JsonArray array = new JsonArray(); + reader.beginArray(); + while (reader.hasNext()) array.add(value(reader)); + reader.endArray(); + yield array; + } + case STRING -> new JsonPrimitive(reader.nextString()); + case NUMBER -> new JsonPrimitive(new BigDecimal(reader.nextString())); + case BOOLEAN -> new JsonPrimitive(reader.nextBoolean()); + case NULL -> { + reader.nextNull(); + yield JsonNull.INSTANCE; + } + default -> throw new IOException("Unexpected JSON value"); + }; + } + + public static UUID uuid(JsonElement value) { + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) + return null; + try { + UUID id = UUID.fromString(value.getAsString()); + return id.toString().equalsIgnoreCase(value.getAsString()) ? id : null; + } catch (RuntimeException ex) { + return null; + } + } + + public static String string(JsonObject object, String key) { + JsonElement value = object.get(key); + return value == null || value.isJsonNull() + ? null + : value.isJsonPrimitive() && value.getAsJsonPrimitive().isString() + ? value.getAsString() + : null; + } + + public static long integer(JsonElement value) throws IOException { + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) + throw new IOException("Expected numeric integer"); + try { + return value.getAsBigDecimal().longValueExact(); + } catch (ArithmeticException ex) { + throw new IOException("Expected supported integer", ex); + } + } + + public static JsonElement overlay(JsonElement before, JsonElement known) { + if (before != null && before.isJsonObject() && known.isJsonObject()) { + JsonObject next = before.getAsJsonObject().deepCopy(); + known.getAsJsonObject() + .entrySet() + .forEach( + entry -> + next.add( + entry.getKey(), + overlay(next.get(entry.getKey()), entry.getValue()))); + return next; + } + return known.deepCopy(); + } +} diff --git a/src/main/java/net/tfminecraft/games/ownership/TLibsCharacters.java b/src/main/java/net/tfminecraft/games/ownership/TLibsCharacters.java new file mode 100644 index 0000000..f0eb303 --- /dev/null +++ b/src/main/java/net/tfminecraft/games/ownership/TLibsCharacters.java @@ -0,0 +1,58 @@ +package net.tfminecraft.games.ownership; + +import net.tfminecraft.tlibs.character.Characters; + +import org.bukkit.entity.Player; + +import java.util.Optional; +import java.util.UUID; + +final class TLibsCharacters { + private TLibsCharacters() {} + + static OwnerSession capture(Player player) { + if (!player.isOnline()) return null; + return Characters.currentSession(player) + .filter(token -> token.ref().ownerId().equals(player.getUniqueId())) + .filter( + token -> + Characters.info(token.ref().characterId()) + .filter( + info -> + info.alive() + && info.ref().equals(token.ref())) + .isPresent()) + .filter(Characters::isCurrent) + .map( + token -> + new OwnerSession( + token.ref().ownerId(), + token.ref().characterId(), + token.sessionId())) + .orElse(null); + } + + static boolean heirReady() { + return Characters.legacyHeirsReady(); + } + + static boolean deals(net.tfminecraft.games.table.Table table, Player player) { + OwnerSession token = CharacterAccess.capture(player); + var captured = table.wagerCharacters().get(player.getUniqueId()); + return token != null + && captured != null + && token.account().equals(captured.ownerId()) + && token.character().equals(captured.characterId()); + } + + static Optional heir(UUID account) { + return Characters.legacyHeir(account) + .filter(ref -> ref.ownerId().equals(account)) + .map(ref -> ref.characterId()); + } + + static void start(org.bukkit.plugin.Plugin plugin) { + org.bukkit.Bukkit.getPluginManager().registerEvents(new CharacterLifecycle(), plugin); + org.bukkit.Bukkit.getOnlinePlayers().forEach(CharacterAccess::capture); + } +} diff --git a/src/main/java/net/tfminecraft/games/ownership/TableEstate.java b/src/main/java/net/tfminecraft/games/ownership/TableEstate.java new file mode 100644 index 0000000..f2e1d94 --- /dev/null +++ b/src/main/java/net/tfminecraft/games/ownership/TableEstate.java @@ -0,0 +1,721 @@ +package net.tfminecraft.games.ownership; + +import com.google.gson.*; + +import net.tfminecraft.games.persistence.DurableFiles; +import net.tfminecraft.games.table.Table; +import net.tfminecraft.games.table.TableManager; +import net.tfminecraft.games.wager.WagerJournal; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterRevivedEvent; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.tlibs.character.estate.*; + +import org.bukkit.Bukkit; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.ServicePriority; + +import java.io.IOException; +import java.nio.file.*; +import java.util.*; +import java.util.function.BooleanSupplier; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** Pending tables reject new activity; captured games settle before raw ownership alone moves. */ +public final class TableEstate implements EstateParticipant, Listener { + private final Plugin plugin; + private final TableManager manager; + private final Path journals; + Predicate guard = Estates::guardHolds; + Supplier> decisions = Estates::decided; + BooleanSupplier serviceReady = Estates::available; + + public TableEstate(Plugin plugin, TableManager manager) { + this.plugin = plugin; + this.manager = manager; + journals = plugin.getDataFolder().toPath().resolve("estates/tables"); + } + + public static void start(Plugin plugin, TableManager manager) { + TableEstate participant = new TableEstate(plugin, manager); + participant.recover(); + Bukkit.getServicesManager() + .register(EstateParticipant.class, participant, plugin, ServicePriority.Normal); + Bukkit.getPluginManager().registerEvents(participant, plugin); + Bukkit.getScheduler().runTaskTimer(plugin, participant::reconcile, 1L, 72000L); + } + + @Override + public String id() { + return "games"; + } + + @Override + public Holdings describe(UUID character) { + try { + List ids = + manager.ownershipStore().inventory().stream() + .filter( + row -> + row.kind() == OwnershipKind.PRIVATE + && character.equals(row.character())) + .map(row -> "Table: " + row.id()) + .toList(); + return ids.isEmpty() + ? Holdings.NONE + : new Holdings(Map.of("private-tables", ids.size()), ids); + } catch (IOException ex) { + return new Holdings( + Map.of("table-review", 1), List.of("Table inventory requires review")); + } + } + + @Override + public Outcome settle(EstateDecision decision) { + if (!CharacterAccess.heirReady()) + return Outcome.retry("Reviewed table ownership is not ready"); + if (!guard.test(decision)) + return Outcome.retry("The exact table estate decision is not current"); + if (decision.property().kind() != Recipient.Kind.CHARACTER + && !decision.property().isWaiting() + && !decision.property().isFrozen()) + return Outcome.blocked( + "Private table property cannot be assigned to this recipient kind"); + if (decision.property().character().equals(Optional.of(decision.testator()))) + return Outcome.blocked("Private table property cannot be transferred to its testator"); + try { + List rows = manager.ownershipStore().inventory(); + if (rows.stream() + .anyMatch( + row -> + decision.testator().ownerId().equals(row.account()) + && row.kind() != OwnershipKind.GUILD + && !OwnershipKind.modeRecorded(row.raw()))) + return Outcome.retry( + "Legacy table funding mode must be frozen before property planning"); + if (rows.stream() + .anyMatch( + row -> + row.kind() == OwnershipKind.PRIVATE + && decision.testator().ownerId().equals(row.account()) + && row.character() == null)) + return Outcome.blocked( + "Legacy private tables for this account need their reviewed heir"); + List moved = new ArrayList<>(); + Path file = journals.resolve(decision.deathId() + "-" + decision.revision() + ".json"); + Map history = new LinkedHashMap<>(); + for (Path older : files()) { + JsonObject previous = read(older); + if (!decision.deathId().toString().equals(RawJson.string(previous, "death"))) + continue; + if (previous.get("revision").getAsLong() > decision.revision()) + throw new Invalid("Future table revision evidence forbids decision rollback"); + if (!from(previous).equals(decision.testator())) + throw new Invalid( + "Historical table receipt testator conflicts with this death"); + history.put(older, previous); + } + JsonObject plan = history.get(file); + if (plan != null && !decision.toString().equals(RawJson.string(plan, "decision"))) { + pending(plan, true); + return Outcome.blocked("Table intent conflicts with the exact current decision"); + } + validateHistory(history.values(), rows, decision.testator(), decision.revision(), plan); + for (var entry : history.entrySet()) { + Path older = entry.getKey(); + JsonObject previous = entry.getValue(); + if (previous.get("complete").getAsBoolean()) moved.addAll(ids(previous, "moved")); + else if (!older.equals(file)) { + if (changed(previous, rows)) { + pending(previous, true); + return Outcome.blocked( + "Earlier partial table transfer requires review: " + + older.getFileName()); + } + previous.addProperty("complete", true); + previous.addProperty("obsolete", true); + previous.add("moved", new JsonArray()); + DurableFiles.write(older, RawJson.JSON.toJson(previous)); + pending(previous, false); + } + } + for (var row : rows) + if (owned(row, decision.testator()) && moved.contains(row.id())) { + manager.estatePending(List.of(row.id()), true); + return Outcome.blocked( + "Completed table reappeared at the testator: " + row.id()); + } + if (decision.property().character().isEmpty()) { + if (plan == null) plan = retain(decision, rows, moved); + DurableFiles.write(file, RawJson.JSON.toJson(plan)); + return done(moved); + } + if (plan != null) { + if (plan.get("complete").getAsBoolean()) { + if (rows.stream().anyMatch(row -> owned(row, decision.testator()))) + return Outcome.blocked( + "Completed table receipt has remaining testator property; review a" + + " new revision"); + return done(moved); + } + } else { + plan = create(decision, rows); + DurableFiles.write(file, RawJson.JSON.toJson(plan)); + } + pending(plan, true); + List candidates = ids(plan, "candidates"); + if (rows.stream() + .anyMatch( + row -> + owned(row, decision.testator()) + && !candidates.contains(row.id()))) + throw new Invalid( + "New private property appeared outside the frozen candidate inventory"); + if (!plan.get("ready").getAsBoolean()) { + for (UUID id : candidates) { + TableOwnershipStore.Row row = find(rows, id); + if (row == null || !owned(row, decision.testator())) + return Outcome.blocked("Pending table source changed: " + id); + Outcome wait = idle(row); + if (wait != null) return wait; + Table table = manager.table(id); + if (table != null) manager.checkpoint(table); + } + rows = manager.ownershipStore().inventory(); + JsonArray snapshots = new JsonArray(); + CharacterRef recipient = decision.property().character().orElseThrow(); + for (UUID id : candidates) { + var row = find(rows, id); + if (row == null || !owned(row, decision.testator())) + throw new Invalid("Table changed before freezing its source: " + id); + Outcome wait = idle(row); + if (wait != null) return wait; + JsonObject entry = new JsonObject(); + entry.addProperty("id", id.toString()); + entry.addProperty("file", row.path().getFileName().toString()); + entry.add("before", row.raw().deepCopy()); + JsonObject after = row.raw().deepCopy(); + after.addProperty("ownerPlayer", recipient.ownerId().toString()); + after.addProperty("ownerCharacterId", recipient.characterId().toString()); + after.addProperty("ownerIdentityVersion", 2); + entry.add("after", after); + snapshots.add(entry); + } + if (!guard.test(decision)) + return Outcome.retry("Estate changed before table source planning"); + plan.add("tables", snapshots); + plan.addProperty("ready", true); + DurableFiles.write(file, RawJson.JSON.toJson(plan)); + } + for (JsonElement item : plan.getAsJsonArray("tables")) { + JsonObject snapshot = item.getAsJsonObject(); + UUID id = RawJson.uuid(snapshot.get("id")); + var row = + manager.ownershipStore() + .row( + manager.ownershipStore() + .folder() + .resolve(RawJson.string(snapshot, "file"))); + if (!row.raw().equals(snapshot.get("before")) + && !row.raw().equals(snapshot.get("after"))) + return Outcome.blocked("Frozen table source changed: " + id); + Outcome wait = idle(row, true); + if (wait != null) return wait; + if (!guard.test(decision)) + return Outcome.retry("Estate changed before table ownership publication"); + if (row.raw().equals(snapshot.get("before")) + || manager.ownershipStore().publicationPending(id)) + manager.ownershipStore().publish(row, snapshot.getAsJsonObject("after")); + CharacterRef recipient = to(plan); + manager.estateOwner(id, recipient.ownerId(), recipient.characterId()); + } + JsonArray receipt = new JsonArray(); + candidates.forEach(id -> receipt.add(id.toString())); + plan.add("moved", receipt); + plan.addProperty("complete", true); + DurableFiles.write(file, RawJson.JSON.toJson(plan)); + pending(plan, false); + moved.addAll(candidates); + return done(moved); + } catch (Invalid | TableOwnershipStore.Conflict | RuntimeException ex) { + holdAll(); + plugin.getLogger().severe("Table estate requires review: " + ex.getMessage()); + return Outcome.blocked("Table estate requires review: " + ex.getMessage()); + } catch (IOException ex) { + return Outcome.retry("Table estate persistence awaits retry: " + ex.getMessage()); + } + } + + private Outcome idle(TableOwnershipStore.Row row) throws IOException { + return idle(row, false); + } + + private Outcome idle(TableOwnershipStore.Row row, boolean replay) throws IOException { + Table table = manager.table(row.id()); + Path intent = + plugin.getDataFolder() + .toPath() + .resolve("Data/wager-intents") + .resolve(row.id() + ".json"); + if (Files.exists(intent)) + return table != null && WagerJournal.active(table) + ? Outcome.retry("Table " + row.id() + ": captured money operation is active") + : Outcome.blocked( + "Table " + row.id() + ": retained wager intent requires review"); + if (table != null + && table.ownershipHeld() + && !(replay + && manager.ownershipStore().publicationPending(row.id()) + && !table.ownershipReviewHeld())) + return Outcome.blocked( + "Table " + row.id() + ": ownership persistence is held for review"); + if (table != null && table.moneyBlocked()) + return Outcome.blocked("Table " + row.id() + ": money is held for review"); + if (table != null + && (table.live() + || table.isPaying() + || !table.payoutFlying().isEmpty() + || table.isRecycling())) + return Outcome.retry("Table " + row.id() + ": captured game/payout must finish"); + JsonObject raw = row.raw(); + for (String key : List.of("ownershipLive", "ownershipPaying")) + if (raw.has(key) && !raw.get(key).isJsonNull()) { + if (!raw.get(key).isJsonPrimitive() + || !raw.get(key).getAsJsonPrimitive().isBoolean()) + throw new Invalid("Invalid saved activity flag for table " + row.id()); + if (raw.get(key).getAsBoolean()) + return Outcome.blocked( + "Table " + row.id() + ": saved activity requires review"); + } + for (String key : List.of("ledger", "piles")) + if (raw.has(key) && !raw.get(key).isJsonNull()) { + if (!raw.get(key).isJsonArray()) + throw new Invalid("Invalid saved money rows for table " + row.id()); + if (!raw.getAsJsonArray(key).isEmpty()) + return Outcome.blocked("Table " + row.id() + ": unsettled saved money remains"); + } + if ((raw.has("houseFloat") + && !raw.get("houseFloat").isJsonNull() + && moneyInteger(raw.get("houseFloat")) != 0) + || (raw.has("floatOwner") && !raw.get("floatOwner").isJsonNull())) + return Outcome.blocked("Table " + row.id() + ": dealer/house float remains"); + if (table != null + && (!table.ledger().isEmpty() + || table.houseFloat() != 0 + || table.floatOwner() != null + || !table.getHands().isEmpty() + || !table.tablePilesEmpty())) + return Outcome.blocked("Table " + row.id() + ": runtime stakes, float or cards remain"); + if (table == null + && raw.has("actives") + && (!raw.get("actives").isJsonArray() || !raw.getAsJsonArray("actives").isEmpty())) + return Outcome.blocked("Table " + row.id() + ": unloaded saved seats require review"); + return null; + } + + private static long moneyInteger(JsonElement value) throws Invalid { + try { + return RawJson.integer(value); + } catch (IOException ex) { + throw new Invalid("Invalid saved money value"); + } + } + + private JsonObject create(EstateDecision decision, List rows) { + JsonObject plan = new JsonObject(); + plan.addProperty("death", decision.deathId().toString()); + plan.addProperty("revision", decision.revision()); + plan.addProperty("decision", decision.toString()); + plan.addProperty("fromAccount", decision.testator().ownerId().toString()); + plan.addProperty("fromCharacter", decision.testator().characterId().toString()); + var recipient = decision.property().character().orElseThrow(); + plan.addProperty("toAccount", recipient.ownerId().toString()); + plan.addProperty("toCharacter", recipient.characterId().toString()); + JsonArray candidates = new JsonArray(); + rows.stream() + .filter(row -> owned(row, decision.testator())) + .forEach(row -> candidates.add(row.id().toString())); + plan.add("candidates", candidates); + plan.add("tables", new JsonArray()); + plan.add("moved", new JsonArray()); + plan.addProperty("ready", false); + plan.addProperty("complete", false); + return plan; + } + + private JsonObject retain( + EstateDecision decision, List rows, List moved) { + JsonObject plan = new JsonObject(); + plan.addProperty("death", decision.deathId().toString()); + plan.addProperty("revision", decision.revision()); + plan.addProperty("decision", decision.toString()); + plan.addProperty("fromAccount", decision.testator().ownerId().toString()); + plan.addProperty("fromCharacter", decision.testator().characterId().toString()); + plan.addProperty("propertyKind", decision.property().kind().name()); + plan.add("toAccount", JsonNull.INSTANCE); + plan.add("toCharacter", JsonNull.INSTANCE); + JsonArray candidates = new JsonArray(), + retained = new JsonArray(), + receipt = new JsonArray(); + rows.stream() + .filter(row -> owned(row, decision.testator())) + .forEach( + row -> { + candidates.add(row.id().toString()); + JsonObject ref = new JsonObject(); + ref.addProperty("id", row.id().toString()); + ref.addProperty("account", row.account().toString()); + ref.addProperty("character", row.character().toString()); + retained.add(ref); + }); + moved.stream().distinct().sorted().forEach(id -> receipt.add(id.toString())); + plan.add("candidates", candidates); + plan.add("retained", retained); + plan.add("tables", new JsonArray()); + plan.add("moved", receipt); + plan.addProperty("ready", true); + plan.addProperty("complete", true); + return plan; + } + + private static void validateHistory( + Collection history, + List rows, + CharacterRef source, + long revision, + JsonObject current) + throws IOException { + Set cumulative = new HashSet<>(), pool = null; + JsonObject latest = null; + List ordered = + history.stream() + .sorted(Comparator.comparingLong(plan -> plan.get("revision").getAsLong())) + .toList(); + for (JsonObject plan : ordered) { + if (!plan.get("complete").getAsBoolean() + || (plan.has("obsolete") && plan.get("obsolete").getAsBoolean())) continue; + Set candidates = Set.copyOf(ids(plan, "candidates")); + if (retained(plan)) { + if (!cumulative.equals(Set.copyOf(ids(plan, "moved")))) + throw new Invalid("Retained completion lost cumulative moved IDs"); + pool = candidates; + } else { + if (!Collections.disjoint(cumulative, ids(plan, "moved"))) + throw new Invalid("Historical table was completed twice"); + cumulative.addAll(ids(plan, "moved")); + pool = Set.of(); + } + latest = plan; + } + if (latest == null || latest.get("revision").getAsLong() != revision) return; + for (var row : rows) + if (owned(row, source) && !pool.contains(row.id())) + throw new Invalid( + "New or restored table appeared after completed inventory: " + row.id()); + if (retained(latest)) + for (UUID id : pool) { + var row = find(rows, id); + if (row == null + || !source.ownerId().equals(row.account()) + || !source.characterId().equals(row.character())) { + if (!publishedBy(current, row, id)) + throw new Invalid("Retained table owner changed after completion: " + id); + } + } + } + + private static boolean publishedBy(JsonObject plan, TableOwnershipStore.Row row, UUID id) { + if (plan == null + || row == null + || retained(plan) + || plan.get("complete").getAsBoolean() + || !plan.get("ready").getAsBoolean()) return false; + for (JsonElement item : plan.getAsJsonArray("tables")) { + JsonObject snapshot = item.getAsJsonObject(); + if (id.equals(RawJson.uuid(snapshot.get("id"))) + && row.raw().equals(snapshot.get("after"))) return true; + } + return false; + } + + private static boolean retained(JsonObject plan) { + return plan.has("propertyKind") + && !"CHARACTER".equals(RawJson.string(plan, "propertyKind")); + } + + private List files() throws IOException { + if (!Files.isDirectory(journals)) return List.of(); + try (var files = Files.list(journals)) { + return files.filter(path -> path.getFileName().toString().endsWith(".json")) + .sorted() + .toList(); + } + } + + private JsonObject read(Path file) throws IOException { + String text = Files.readString(file); + JsonObject plan; + try { + plan = RawJson.parse(text); + validate(plan, file); + } catch (IOException | RuntimeException ex) { + throw new Invalid( + "Invalid table journal " + file.getFileName() + ": " + ex.getMessage()); + } + return plan; + } + + private static void validate(JsonObject plan, Path file) throws IOException { + UUID death = RawJson.uuid(plan.get("death")); + long revision = RawJson.integer(plan.get("revision")); + CharacterRef source = from(plan), recipient = null; + String kind = plan.has("propertyKind") ? RawJson.string(plan, "propertyKind") : "CHARACTER"; + Recipient property; + if ("CHARACTER".equals(kind)) { + recipient = to(plan); + property = Recipient.character(recipient); + } else if ("WAITING".equals(kind) || "FROZEN".equals(kind)) { + property = "WAITING".equals(kind) ? Recipient.WAITING : Recipient.FROZEN; + for (String key : List.of("toAccount", "toCharacter")) + if (!plan.has(key) || !plan.get(key).isJsonNull()) + throw new Invalid("Retained completion has a character recipient"); + } else throw new Invalid("Unsupported saved property recipient"); + String decision = RawJson.string(plan, "decision"); + if (death == null + || revision < 0 + || !file.getFileName().toString().equals(death + "-" + revision + ".json") + || decision == null + || !decision.startsWith( + "EstateDecision[deathId=" + death + ", testator=" + source + ", diedAt=") + || !decision.contains(", property=" + property + ", guildLocks=") + || !decision.endsWith(", revision=" + revision + "]")) + throw new Invalid("Decision envelope mismatch"); + for (String key : List.of("ready", "complete")) + if (!plan.has(key) + || !plan.get(key).isJsonPrimitive() + || !plan.get(key).getAsJsonPrimitive().isBoolean()) + throw new Invalid("Invalid " + key); + if (plan.has("obsolete") + && (!plan.get("obsolete").isJsonPrimitive() + || !plan.get("obsolete").getAsJsonPrimitive().isBoolean() + || (plan.get("obsolete").getAsBoolean() + && !plan.get("complete").getAsBoolean()))) + throw new Invalid("Invalid obsolete receipt marker"); + List candidates = ids(plan, "candidates"), moved = ids(plan, "moved"); + if (!plan.has("tables") || !plan.get("tables").isJsonArray()) + throw new Invalid("Missing table snapshots"); + if (retained(plan)) { + if (!plan.get("ready").getAsBoolean() + || !plan.get("complete").getAsBoolean() + || !plan.getAsJsonArray("tables").isEmpty() + || (plan.has("obsolete") && plan.get("obsolete").getAsBoolean()) + || !plan.has("retained") + || !plan.get("retained").isJsonArray()) + throw new Invalid("Invalid retained completion"); + Set refs = new HashSet<>(); + for (JsonElement item : plan.getAsJsonArray("retained")) { + JsonObject ref = item.getAsJsonObject(); + UUID id = RawJson.uuid(ref.get("id")); + if (id == null || !refs.add(id) || !source.equals(ref(ref, "account", "character"))) + throw new Invalid("Retained inventory reference mismatch"); + } + if (!refs.equals(Set.copyOf(candidates)) || !Collections.disjoint(candidates, moved)) + throw new Invalid("Retained inventory IDs disagree"); + return; + } + if (plan.has("retained") + && (!plan.get("retained").isJsonArray() + || !plan.getAsJsonArray("retained").isEmpty())) + throw new Invalid("Character completion retained unexpected property"); + Set snapshots = new HashSet<>(); + for (JsonElement item : plan.getAsJsonArray("tables")) { + JsonObject entry = item.getAsJsonObject(); + UUID id = RawJson.uuid(entry.get("id")); + String name = RawJson.string(entry, "file"); + if (id == null + || !snapshots.add(id) + || name == null + || !Path.of(name).getFileName().toString().equals(name) + || !name.endsWith(".json")) + throw new Invalid("Invalid table snapshot envelope"); + JsonObject before = entry.getAsJsonObject("before"), + after = entry.getAsJsonObject("after"); + OwnershipMigration.validateRow(before); + OwnershipMigration.validateRow(after); + if (!id.equals(RawJson.uuid(before.get("id"))) + || OwnershipKind.of(before) != OwnershipKind.PRIVATE + || !source.ownerId().equals(RawJson.uuid(before.get("ownerPlayer"))) + || !source.characterId().equals(RawJson.uuid(before.get("ownerCharacterId")))) + throw new Invalid("Invalid source snapshot"); + JsonObject expected = before.deepCopy(); + expected.addProperty("ownerPlayer", recipient.ownerId().toString()); + expected.addProperty("ownerCharacterId", recipient.characterId().toString()); + expected.addProperty("ownerIdentityVersion", 2); + if (!expected.equals(after)) + throw new Invalid("Table snapshot changed beyond ownership"); + } + if (plan.get("ready").getAsBoolean() + ? !snapshots.equals(Set.copyOf(candidates)) + : !snapshots.isEmpty()) throw new Invalid("Snapshot candidate IDs disagree"); + if (plan.get("complete").getAsBoolean() + && (plan.has("obsolete") && plan.get("obsolete").getAsBoolean() + ? !moved.isEmpty() + : !plan.get("ready").getAsBoolean() + || !Set.copyOf(moved).equals(Set.copyOf(candidates)))) + throw new Invalid("Completed receipt IDs disagree"); + } + + private static List ids(JsonObject plan, String key) throws IOException { + if (!plan.has(key) || !plan.get(key).isJsonArray()) + throw new Invalid("Invalid " + key + " list"); + List ids = new ArrayList<>(); + for (JsonElement item : plan.getAsJsonArray(key)) { + UUID id = RawJson.uuid(item); + if (id == null || ids.contains(id)) throw new Invalid("Invalid/duplicate holding ID"); + ids.add(id); + } + return ids; + } + + private static CharacterRef from(JsonObject plan) throws IOException { + return ref(plan, "fromAccount", "fromCharacter"); + } + + private static CharacterRef to(JsonObject plan) throws IOException { + return ref(plan, "toAccount", "toCharacter"); + } + + private static CharacterRef ref(JsonObject plan, String account, String character) + throws IOException { + UUID owner = RawJson.uuid(plan.get(account)), id = RawJson.uuid(plan.get(character)); + if (owner == null || id == null) throw new Invalid("Invalid character reference"); + return new CharacterRef(owner, id); + } + + private static boolean owned(TableOwnershipStore.Row row, CharacterRef owner) { + return row.kind() == OwnershipKind.PRIVATE + && owner.ownerId().equals(row.account()) + && owner.characterId().equals(row.character()); + } + + private static TableOwnershipStore.Row find(List rows, UUID id) { + return rows.stream().filter(row -> id.equals(row.id())).findFirst().orElse(null); + } + + private static boolean changed(JsonObject plan, List rows) + throws IOException { + if (plan.get("ready").getAsBoolean()) { + for (JsonElement item : plan.getAsJsonArray("tables")) { + JsonObject entry = item.getAsJsonObject(); + var row = find(rows, RawJson.uuid(entry.get("id"))); + if (row == null || !row.raw().equals(entry.get("before"))) return true; + } + } else + for (UUID id : ids(plan, "candidates")) { + var row = find(rows, id); + if (row == null || !owned(row, from(plan))) return true; + } + return false; + } + + private void pending(JsonObject plan, boolean pending) throws IOException { + manager.estatePending(ids(plan, "candidates"), pending); + } + + void recover() { + try { + for (Path file : files()) { + JsonObject plan = read(file); + if (!plan.get("complete").getAsBoolean()) pending(plan, true); + } + } catch (IOException | RuntimeException ex) { + holdAll(); + plugin.getLogger().severe("Table estate journals need review: " + ex.getMessage()); + } + } + + private void holdAll() { + manager.tables() + .forEach( + table -> { + table.setOwnershipHeld(true); + table.setEstatePending(true); + }); + try { + manager.estatePending( + manager.ownershipStore().inventory().stream() + .map(TableOwnershipStore.Row::id) + .toList(), + true); + } catch (IOException ignored) { + /* Broken raw files remain unloaded and unchanged. */ + } + } + + void reconcile() { + releaseCancelled(); + for (EstateDecision decision : decisions.get()) + if (guard.test(decision)) + Estates.report(decision.deathId(), id(), decision.revision(), settle(decision)); + } + + void releaseCancelled() { + if (!CharacterAccess.heirReady() || !serviceReady.getAsBoolean()) return; + try { + List rows = manager.ownershipStore().inventory(); + for (Path file : files()) { + JsonObject plan = read(file); + if (plan.get("complete").getAsBoolean()) continue; + CharacterRef source = from(plan); + UUID death = RawJson.uuid(plan.get("death")); + var info = + Characters.info(source.characterId()) + .filter(value -> value.ref().equals(source)); + var currentDeath = Characters.deathId(source.characterId()); + if (info.isEmpty() + || (!info.get().alive() + && (currentDeath.isEmpty() || currentDeath.get().equals(death)))) + continue; + if (changed(plan, rows)) { + pending(plan, true); + continue; + } + plan.addProperty("complete", true); + plan.addProperty("obsolete", true); + plan.add("moved", new JsonArray()); + DurableFiles.write(file, RawJson.JSON.toJson(plan)); + pending(plan, false); + } + } catch (Invalid | TableOwnershipStore.Conflict | RuntimeException ex) { + holdAll(); + plugin.getLogger().severe("Cancelled table intent requires review: " + ex.getMessage()); + } catch (IOException ex) { + plugin.getLogger() + .warning("Cancelled table intent awaits persistence retry: " + ex.getMessage()); + } + } + + @EventHandler + public void onEstate(EstateDecidedEvent event) { + Bukkit.getScheduler().runTask(plugin, this::reconcile); + } + + @EventHandler + public void onRevive(CharacterRevivedEvent event) { + Bukkit.getScheduler().runTask(plugin, this::reconcile); + } + + private static Outcome done(List ids) { + List moved = ids.stream().map(UUID::toString).distinct().sorted().toList(); + return Outcome.done(Map.of("private-tables", moved.size()), moved); + } + + private static final class Invalid extends IOException { + Invalid(String message) { + super(message); + } + } +} diff --git a/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java b/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java new file mode 100644 index 0000000..a58180d --- /dev/null +++ b/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java @@ -0,0 +1,269 @@ +package net.tfminecraft.games.ownership; + +import com.google.gson.*; + +import net.tfminecraft.games.persistence.DurableFiles; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +/** Raw inventory includes tables that cannot currently create a world/card-set runtime body. */ +public final class TableOwnershipStore { + public static final class Conflict extends IOException { + public Conflict(String message) { + super(message); + } + } + + public record Row( + Path path, UUID id, UUID account, UUID character, OwnershipKind kind, JsonObject raw) {} + + private record Source(Path path, String hash, JsonObject raw) {} + + private final Path folder; + private final OwnershipMigration migration; + private final Map sources = new HashMap<>(); + private final Set conflicts = new HashSet<>(); + private final Set publicationPending = new HashSet<>(); + private final Map> knownWagers = new HashMap<>(); + + public TableOwnershipStore(Path data) { + folder = data.resolve("Data/tables"); + migration = new OwnershipMigration(data.resolve("migrations/table-ownership")); + } + + public Path folder() { + return folder; + } + + public boolean prepare(Path file) throws IOException { + UUID existing = null; + for (var entry : sources.entrySet()) + if (entry.getValue().path().equals(file)) existing = entry.getKey(); + if (existing != null) check(existing); + boolean usable = migration.prepare(file); + captureSource(file); + return usable; + } + + public void observe(Path file) throws IOException { + for (var entry : sources.entrySet()) + if (entry.getValue().path().equals(file)) check(entry.getKey()); + captureSource(file); + } + + private void captureSource(Path file) throws IOException { + JsonObject raw = RawJson.read(file); + OwnershipMigration.validateRow(raw); + UUID id = RawJson.uuid(raw.get("id")); + if (id == null) throw new IOException("Invalid table ID"); + Source previous = sources.get(id); + if (previous != null && !previous.path().equals(file)) { + conflicts.add(id); + throw new Conflict("Duplicate table ID"); + } + sources.put(id, new Source(file, OwnershipMigration.hash(Files.readAllBytes(file)), raw)); + rememberWagers(id, raw); + } + + public Row row(Path file) throws IOException { + String text = Files.readString(file); + JsonObject raw; + try { + raw = RawJson.parse(text); + OwnershipMigration.validateRow(raw); + } catch (IOException ex) { + throw new Conflict( + "Invalid table source: " + file.getFileName() + ": " + ex.getMessage()); + } + UUID id = RawJson.uuid(raw.get("id")); + if (id == null) throw new Conflict("Invalid table ID"); + return new Row( + file, + id, + RawJson.uuid(raw.get("ownerPlayer")), + RawJson.uuid(raw.get("ownerCharacterId")), + OwnershipKind.of(raw), + raw); + } + + public List inventory() throws IOException { + if (!Files.isDirectory(folder)) return List.of(); + List rows = new ArrayList<>(); + Set ids = new HashSet<>(); + try (var files = Files.list(folder)) { + for (Path file : + files.filter(path -> path.getFileName().toString().endsWith(".json")) + .sorted() + .toList()) { + Row row = row(file); + if (!ids.add(row.id())) { + conflicts.add(row.id()); + throw new Conflict("Duplicate table ID: " + row.id()); + } + if (sources.containsKey(row.id())) check(row.id()); + rows.add(row); + } + } + return List.copyOf(rows); + } + + public boolean held(UUID id) { + return conflicts.contains(id) || publicationPending.contains(id); + } + + public boolean publicationPending(UUID id) { + return publicationPending.contains(id); + } + + public void hold(UUID id) { + conflicts.add(id); + } + + public void check(UUID id) throws IOException { + Source source = sources.get(id); + try { + if (conflicts.contains(id) + || (source != null + && (!Files.isRegularFile(source.path()) + || !source.hash() + .equals( + OwnershipMigration.hash( + Files.readAllBytes(source.path())))))) { + throw new Conflict("Table source changed outside its writer: " + id); + } + } catch (IOException ex) { + conflicts.add(id); + throw ex; + } + } + + public void save(UUID id, JsonObject known) throws IOException { + if (publicationPending.contains(id)) + throw new Conflict("Ownership publication still awaits durable acknowledgement: " + id); + check(id); + Source previous = sources.get(id); + Path file = previous == null ? folder.resolve(id + ".json") : previous.path(); + if (previous == null && Files.exists(file)) { + conflicts.add(id); + throw new Conflict("Unexpected existing table source: " + id); + } + JsonObject next = previous == null ? known.deepCopy() : overlay(id, previous.raw(), known); + OwnershipMigration.validateRow(next); + String text = RawJson.JSON.toJson(next); + DurableFiles.write(file, text); + sources.put( + id, + new Source( + file, + OwnershipMigration.hash( + text.getBytes(java.nio.charset.StandardCharsets.UTF_8)), + next)); + rememberWagers(id, next); + } + + public void publish(Row before, JsonObject next) throws IOException { + if (!before.raw().equals(row(before.path()).raw())) { + conflicts.add(before.id()); + throw new Conflict("Estate source changed after planning"); + } + check(before.id()); + OwnershipMigration.validateRow(next); + String text = RawJson.JSON.toJson(next); + String afterHash = + OwnershipMigration.hash(text.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + try { + DurableFiles.write(before.path(), text); + } catch (IOException ex) { + // Exact attempted bytes are a replay candidate, never acknowledgement or general write + // authority. + if (Files.isRegularFile(before.path()) + && afterHash.equals( + OwnershipMigration.hash(Files.readAllBytes(before.path())))) { + sources.put(before.id(), new Source(before.path(), afterHash, next)); + publicationPending.add(before.id()); + } + throw ex; + } + publicationPending.remove(before.id()); + sources.put( + before.id(), + new Source( + before.path(), + OwnershipMigration.hash( + text.getBytes(java.nio.charset.StandardCharsets.UTF_8)), + next)); + rememberWagers(before.id(), next); + } + + private JsonObject overlay(UUID id, JsonObject old, JsonObject known) { + JsonObject next = RawJson.overlay(old, known).getAsJsonObject(); + if (old.has("ledger") + && old.get("ledger").isJsonArray() + && known.has("ledger") + && known.get("ledger").isJsonArray()) { + JsonArray ledger = new JsonArray(); + for (JsonElement entry : known.getAsJsonArray("ledger")) { + JsonElement merged = entry; + for (JsonElement prior : old.getAsJsonArray("ledger")) + if (stakeKey(prior).equals(stakeKey(entry))) { + merged = RawJson.overlay(prior, entry); + break; + } + ledger.add(merged); + } + next.add("ledger", ledger); + } + if (old.has("wagerCharacters") && old.get("wagerCharacters").isJsonObject()) { + JsonObject refs = + known.has("wagerCharacters") && known.get("wagerCharacters").isJsonObject() + ? known.getAsJsonObject("wagerCharacters").deepCopy() + : new JsonObject(); + Set consumed = knownWagers.getOrDefault(id, Set.of()); + old.getAsJsonObject("wagerCharacters") + .entrySet() + .forEach( + entry -> { + if (!consumed.contains(entry.getKey())) + refs.add(entry.getKey(), entry.getValue().deepCopy()); + }); + next.add("wagerCharacters", refs); + } + return next; + } + + private static JsonElement stakeKey(JsonElement row) { + if (!row.isJsonObject()) return row; + JsonObject key = row.getAsJsonObject().deepCopy(); + key.remove("count"); + JsonObject identity = new JsonObject(); + for (String field : + List.of( + "owner", + "item", + "typeKey", + "unit", + "streetId", + "anchorX", + "anchorZ", + "x", + "z")) + identity.add(field, key.has(field) ? key.get(field) : JsonNull.INSTANCE); + return identity; + } + + private void rememberWagers(UUID id, JsonObject raw) { + Set consumed = knownWagers.computeIfAbsent(id, ignored -> new HashSet<>()); + if (raw.has("wagerCharacters") && raw.get("wagerCharacters").isJsonObject()) + raw.getAsJsonObject("wagerCharacters") + .entrySet() + .forEach( + entry -> { + if (RawJson.uuid(new JsonPrimitive(entry.getKey())) != null + && RawJson.uuid(entry.getValue()) != null) + consumed.add(entry.getKey()); + }); + } +} diff --git a/src/main/java/net/tfminecraft/games/table/Table.java b/src/main/java/net/tfminecraft/games/table/Table.java index 6a5c6ff..5fbcea2 100644 --- a/src/main/java/net/tfminecraft/games/table/Table.java +++ b/src/main/java/net/tfminecraft/games/table/Table.java @@ -60,6 +60,12 @@ public final class Table { private int maxBet; private int autoCountdown; private UUID ownerPlayer; + private UUID ownerCharacterId; + private long ownerGeneration; + private boolean ownershipHeld; + private boolean estatePending; + private java.util.function.BooleanSupplier ownershipHealthy = () -> true; + private java.util.function.BooleanSupplier ownershipWriteReady = () -> true; private boolean moneyBlocked; private final java.util.Map wagerCharacters = new java.util.HashMap<>(); @@ -292,6 +298,7 @@ public UUID actor() { public void setActor(UUID actor) { this.actor = actor; + TableManager.get().chatActorChanged(this); } public String phase() { @@ -300,6 +307,7 @@ public String phase() { public void setPhase(String phase) { this.phase = phase; + TableManager.get().chatActorChanged(this); } public List boxes() { @@ -373,8 +381,20 @@ public UUID ownerPlayer() { } public void setOwnerPlayer(UUID ownerPlayer) { + if (!Objects.equals(this.ownerPlayer,ownerPlayer)) { ownerCharacterId = null;ownerGeneration++; } this.ownerPlayer = ownerPlayer; } + public UUID ownerCharacterId() { return ownerCharacterId; } + public void setOwnerCharacterId(UUID character) { if(!Objects.equals(ownerCharacterId,character)) ownerGeneration++;ownerCharacterId = character; } + public long ownerGeneration() { return ownerGeneration; } + public boolean ownershipHeld() { return ownershipHeld || !ownershipHealthy.getAsBoolean(); } + public boolean ownershipReviewHeld() { return ownershipHeld; } + public void setOwnershipHeld(boolean held) { ownershipHeld = held; } + public void ownershipHealthy(java.util.function.BooleanSupplier check) { ownershipHealthy = check; } + public void ownershipWriteReady(java.util.function.BooleanSupplier check) { ownershipWriteReady = check; } + public boolean ownershipWriteReady() { return !ownershipHeld() && ownershipWriteReady.getAsBoolean(); } + public boolean estatePending() { return estatePending; } + public void setEstatePending(boolean pending) { estatePending = pending; } public String ownerGuildId() { return ownerGuildId; @@ -476,7 +496,12 @@ public void setFloatOwner(UUID floatOwner) { /** The player who put this table down, or whoever is holding its shoe. */ public boolean hostedBy(UUID playerId) { - return playerId.equals(ownerPlayer) || playerId.equals(dealerId); + org.bukkit.entity.Player player = org.bukkit.Bukkit.getPlayer(playerId); + if (player == null || ownershipHeld()) return false; + var kind = net.tfminecraft.games.ownership.OwnershipKind.of(this); + if (!estatePending && kind == net.tfminecraft.games.ownership.OwnershipKind.PRIVATE + && net.tfminecraft.games.ownership.CharacterAccess.owns(this, player)) return true; + return playerId.equals(dealerId) && net.tfminecraft.games.ownership.CharacterAccess.deals(this,player); } /** Whether putting this table down used up a deck, so picking it up should give one back. */ @@ -501,6 +526,7 @@ public void clearSession() { if (moneyBlocked || net.tfminecraft.games.wager.WagerJournal.blocked(this)) return; live = false; actor = null; + TableManager.get().chatActorChanged(this); phase = null; boxes.clear(); boxIndex = 0; diff --git a/src/main/java/net/tfminecraft/games/table/TableHouse.java b/src/main/java/net/tfminecraft/games/table/TableHouse.java index f97eed3..6efaf3e 100644 --- a/src/main/java/net/tfminecraft/games/table/TableHouse.java +++ b/src/main/java/net/tfminecraft/games/table/TableHouse.java @@ -14,6 +14,8 @@ public final class TableHouse { public static final String STAFF_PERM = "games.autodealer.staff"; private UUID ownerPlayer; + private UUID ownerCharacterId; + private long ownerGeneration; private String ownerGuildId; private UUID ownerGuildIncarnation; private boolean autoDealer; @@ -28,6 +30,8 @@ public final class TableHouse { public static TableHouse forPlace(Player player, TableLayout layout) { TableHouse house = new TableHouse(); house.ownerPlayer = player.getUniqueId(); + var token = net.tfminecraft.games.ownership.CharacterAccess.capture(player); + house.ownerCharacterId = token == null ? null : token.character(); boolean staff = player.hasPermission(STAFF_PERM); boolean yamlAuto = layout != null && layout.autoDealer(); if (staff && yamlAuto) { @@ -53,6 +57,8 @@ public static TableHouse forPlace(Player player, TableLayout layout) { public static TableHouse from(Table table) { TableHouse house = new TableHouse(); house.ownerPlayer = table.ownerPlayer(); + house.ownerCharacterId = table.ownerCharacterId(); + house.ownerGeneration = table.ownerGeneration(); house.ownerGuildId = table.ownerGuildId(); house.ownerGuildIncarnation = table.ownerGuildIncarnation(); house.autoDealer = table.autoDealer(); @@ -68,6 +74,7 @@ public static TableHouse from(Table table) { public void apply(Table table) { table.setOwnerPlayer(ownerPlayer); + table.setOwnerCharacterId(ownerCharacterId); table.setOwnerGuildId(ownerGuildId); table.setOwnerGuildIncarnation(ownerGuildIncarnation); table.setAutoDealer(autoDealer); @@ -85,8 +92,16 @@ public UUID ownerPlayer() { } public void setOwnerPlayer(UUID ownerPlayer) { + if (!java.util.Objects.equals(this.ownerPlayer,ownerPlayer)) ownerCharacterId = null; this.ownerPlayer = ownerPlayer; } + public UUID ownerCharacterId() { return ownerCharacterId; } + public long ownerGeneration() { return ownerGeneration; } + public boolean matchesOwner(Table table) { + return ownerGeneration==table.ownerGeneration() && java.util.Objects.equals(ownerPlayer,table.ownerPlayer()) + && java.util.Objects.equals(ownerCharacterId,table.ownerCharacterId()); + } + public void setOwnerCharacterId(UUID character) { ownerCharacterId = character; } public String ownerGuildId() { return ownerGuildId; diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index e18785a..cd3f7de 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -194,6 +194,7 @@ public void stopClock() { } private void tickHands() { + publishChatActors(); handTicks++; for (Table table : tables.values()) { tickAway(table); @@ -261,8 +262,11 @@ public void armPlace(Player player, String gameId, boolean requireDeck) { } public void armPlace(Player player, String gameId, boolean requireDeck, TableHouse house) { + var token = net.tfminecraft.games.ownership.CharacterAccess.capture(player); + if (token == null) return; + if(house!=null) { house.setOwnerPlayer(token.account());house.setOwnerCharacterId(token.character()); } arms.put(player.getUniqueId(), - new PlaceArm(gameId, System.currentTimeMillis() + PLACE_TIMEOUT_MS, requireDeck, house)); + new PlaceArm(gameId, System.currentTimeMillis() + PLACE_TIMEOUT_MS, requireDeck, house, token)); } public void selectGame(Player player, String gameId, Location at, boolean requireDeck) { @@ -274,18 +278,129 @@ public void selectGame(Player player, String gameId, Location at, boolean requir player.sendMessage(Messages.get(requireDeck ? "place.armed" : "place.armed_admin")); } - public void loadAll() { loadTables(false); } + private net.tfminecraft.games.ownership.TableOwnershipStore ownershipStore; + private java.nio.file.Path ownershipRoot; + private final Set estatePendingTables = new HashSet<>(); + private final Set invalidOwnerFiles = new HashSet<>(); + private boolean ownershipInventoryComplete; + public net.tfminecraft.games.ownership.TableOwnershipStore ownershipStore() { + java.nio.file.Path root = Games.plugin.getDataFolder().toPath(); + if (ownershipStore == null || !root.equals(ownershipRoot)) { + ownershipRoot = root;ownershipStore = new net.tfminecraft.games.ownership.TableOwnershipStore(root); + } + return ownershipStore; + } + public boolean ownershipHealthy(UUID id) { + // Full source checks remain at writes, estate publication and the deferred loading pass. + return !ownershipStore().held(id); + } + private boolean ownershipWriteReady(UUID id) { + try { ownershipStore().check(id);return !ownershipStore().held(id); } + catch (IOException ex) { return false; } + } + public void syncOwnership(Table table) { + table.ownershipHealthy(() -> ownershipHealthy(table.getId())); + table.ownershipWriteReady(() -> ownershipWriteReady(table.getId())); + table.setEstatePending(estatePendingTables.contains(table.getId())); + } + public void estatePending(Collection ids, boolean pending) { + for(UUID id:ids) { + if(pending) estatePendingTables.add(id);else estatePendingTables.remove(id); + Table table=tables.get(id);if(table!=null) table.setEstatePending(pending); + } + } + public void checkpoint(Table table) throws IOException { saveWager(table); } + public void estateOwner(UUID id,UUID account,UUID character) { + Table table=tables.get(id); + if(table!=null) { table.setOwnerPlayer(account);table.setOwnerCharacterId(character);refreshLabel(table); } + } + private volatile Map chatActors = Map.of(); + public void publishChatActors() { + Map next = new HashMap<>(); + tables.values().forEach(table -> addChatActor(table, next)); + chatActors = Map.copyOf(next); + } + public void chatActorChanged(Table table) { + Map next = new HashMap<>(chatActors); + next.values().removeIf(table.getId()::equals); + addChatActor(table, next); + chatActors = Map.copyOf(next); + } + private void addChatActor(Table table, Map into) { + if(table.live() && table.actor()!=null) { + Player actor=Bukkit.getPlayer(table.actor()); + Game game = gameOf(table); + if(actor!=null && atTable(actor, table) && game!=null && game.allowPlayChat(table, actor)) + into.put(table.actor(),table.getId()); + } + } + public void loadAll() { ownershipStore = null;loadTables(false); } public void loadDeferred() { loadTables(true); } + private boolean validOwnerFile(File file) throws IOException { + if(invalidOwnerFiles.contains(file.getName()) && knownUnloadable(file)) return false; + String text=java.nio.file.Files.readString(file.toPath()); + try { + var raw=net.tfminecraft.games.ownership.RawJson.parse(text); + net.tfminecraft.games.ownership.OwnershipMigration.validateRow(raw); + if(net.tfminecraft.games.ownership.RawJson.uuid(raw.get("id"))==null) throw new IOException("Invalid table ID"); + } catch(IOException ex) { + invalidOwnerFiles.add(file.getName());markUnloadable(file); + Games.plugin.getLogger().warning("[Games] Failed to load table "+file.getName()+": "+ex.getMessage());return false; + } + invalidOwnerFiles.remove(file.getName());return true; + } + private Set duplicateOwnerIds(File[] files) throws IOException { + ownershipInventoryComplete=true; + Set seen=new HashSet<>(),duplicates=new HashSet<>(); + for(File file:files) { + // No mutation is allowed before the complete raw inventory establishes uniqueness. + com.google.gson.JsonObject raw; + try { raw=net.tfminecraft.games.ownership.RawJson.read(file.toPath()); } + catch(IOException ex) { ownershipInventoryComplete=false;continue; } + UUID id=net.tfminecraft.games.ownership.RawJson.uuid(raw.get("id")); + if(id!=null && !seen.add(id)) duplicates.add(id); + } + duplicates.forEach(ownershipStore()::hold);return duplicates; + } + private void loadTables(boolean onlyMissing) { + for (Table loaded : tables.values()) { + try { ownershipStore().check(loaded.getId()); } + catch (IOException ex) { ownershipStore().hold(loaded.getId()); } + } File folder = tablesFolder(); File[] files = folder.listFiles((dir, name) -> name.endsWith(".json")); if (files == null) { return; } + Set duplicates; + try { duplicates=duplicateOwnerIds(files); } + catch(IOException ex) { + // Cache raw shape failures for the usual repair-and-retry path without publishing others. + for(File file:files) try { validOwnerFile(file); } catch(IOException ignored) { } + return; + } for (File file : files) { try { + if(!validOwnerFile(file)) continue; + UUID rawId=net.tfminecraft.games.ownership.RawJson.uuid(net.tfminecraft.games.ownership.RawJson.read(file.toPath()).get("id")); + if(duplicates.contains(rawId)) { Games.plugin.getLogger().warning("[Games] Duplicate table ID held: "+rawId);continue; } + if(ownershipInventoryComplete) { + if (!net.tfminecraft.games.guild.GuildTableMigration.prepare(file.toPath(), + new File(Games.plugin.getDataFolder(), "migrations/guild-tables").toPath())) continue; + if (!ownershipStore().prepare(file.toPath())) continue; + } else { + var raw=net.tfminecraft.games.ownership.RawJson.read(file.toPath()); + String guild=net.tfminecraft.games.ownership.RawJson.string(raw,"ownerGuildId"); + if((net.tfminecraft.games.ownership.RawJson.uuid(raw.get("ownerPlayer"))!=null && net.tfminecraft.games.ownership.RawJson.uuid(raw.get("ownerCharacterId"))==null) + || (guild!=null && !guild.isBlank() && (!raw.has("ownerGuildMigrated") || raw.get("ownerGuildMigrated").isJsonNull() || !raw.get("ownerGuildMigrated").getAsBoolean()))) continue; + ownershipStore().observe(file.toPath()); + } + var ownerRow = ownershipStore().row(file.toPath()); + Table loaded = tables.get(ownerRow.id()); + if (loaded != null) { loaded.setOwnerCharacterId(ownerRow.character());syncOwnership(loaded); } if (onlyMissing) { if (knownUnloadable(file)) continue; try { @@ -293,8 +408,6 @@ private void loadTables(boolean onlyMissing) { 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 (RuntimeException ex) { // The file's shape is the problem; do not re-read it every pass until it changes. markUnloadable(file); @@ -317,6 +430,7 @@ private void loadTables(boolean onlyMissing) { // A missing world or card set can arrive later, so this stays retryable. continue; } + syncOwnership(table); tables.put(table.getId(), table); unloadableFiles.remove(file.getName()); if (net.tfminecraft.games.wager.WagerJournal.blocked(table)) { @@ -467,6 +581,7 @@ public boolean tryPlace(Player player, Location at) { if (arm == null) { return false; } + if (!net.tfminecraft.games.ownership.CharacterAccess.current(arm.token)) { arms.remove(player.getUniqueId());return true; } if (at == null || at.getWorld() == null) { return false; } @@ -515,7 +630,8 @@ public boolean tryPlace(Player player, Location at) { } house.apply(table); } else { - table.setOwnerPlayer(player.getUniqueId()); + table.setOwnerPlayer(arm.token.account()); + table.setOwnerCharacterId(arm.token.character()); TableLayout layout = Cache.layoutOf(arm.gameId); if (layout != null) { table.setSmallBlind(layout.smallBlind()); @@ -531,6 +647,7 @@ public boolean tryPlace(Player player, Location at) { return true; } table.setDeckConsumed(arm.requireDeck); + syncOwnership(table); tables.put(table.getId(), table); save(table); if (arm.requireDeck) { @@ -610,16 +727,21 @@ public void onSneak(PlayerToggleSneakEvent event) { @EventHandler public void onQuit(PlayerQuitEvent event) { - arms.remove(event.getPlayer().getUniqueId()); - clearLootArm(event.getPlayer().getUniqueId()); - onWagerQuit(event.getPlayer()); - leaveIfAtTable(event.getPlayer(), true); - clearDealer(event.getPlayer()); - selectCooldown.remove(event.getPlayer().getUniqueId()); - handLocks.remove(event.getPlayer().getUniqueId()); - layoutHoldUntil.remove(event.getPlayer().getUniqueId()); - selectAnimGen.remove(event.getPlayer().getUniqueId()); - clearRevealed(event.getPlayer().getUniqueId(), null); + net.tfminecraft.games.ownership.CharacterAccess.ended(event.getPlayer().getUniqueId()); + clearPlayer(event.getPlayer()); + } + public void clearPlacement(UUID account) { arms.remove(account);clearLootArm(account); } + public void clearPlayer(Player player) { + arms.remove(player.getUniqueId()); + clearLootArm(player.getUniqueId()); + onWagerQuit(player); + leaveIfAtTable(player, true); + clearDealer(player); + selectCooldown.remove(player.getUniqueId()); + handLocks.remove(player.getUniqueId()); + layoutHoldUntil.remove(player.getUniqueId()); + selectAnimGen.remove(player.getUniqueId()); + clearRevealed(player.getUniqueId(), null); } /** A dealer who logged off mid-round gets back the tray the round left them. */ @@ -717,11 +839,14 @@ public void onPlayChat(AsyncPlayerChatEvent event) { return; } Player player = event.getPlayer(); - if (!isPlayActor(player)) { - return; - } + var token=net.tfminecraft.games.ownership.CharacterAccess.published(player.getUniqueId()); + UUID tableId = chatActors.get(player.getUniqueId()); + if (token==null || tableId==null) return; event.setCancelled(true); - Bukkit.getScheduler().runTask(Games.plugin, () -> applyPlayCall(player, action)); + Bukkit.getScheduler().runTask(Games.plugin, () -> { + if(net.tfminecraft.games.ownership.CharacterAccess.current(token) + && table(tableId) == tableNearby(player)) applyPlayCall(player, action); + }); } @EventHandler(priority = EventPriority.HIGH) @@ -778,6 +903,10 @@ public void onChunkLoad(ChunkLoadEvent event) { private void pickup(Player player, Table table) { if (table.moneyBlocked() || net.tfminecraft.games.wager.WagerJournal.blocked(table)) return; + if (!table.ownershipWriteReady()) { + player.sendMessage(Messages.get("place.pickup_denied")); + return; + } cancelVote(table, "wager.cancelled"); table.bumpRecycleGen(); cancelLootArmsForTable(table.getId()); @@ -1642,7 +1771,7 @@ 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.ownershipHeld() || table.estatePending() || table.moneyBlocked() || net.tfminecraft.games.wager.WagerJournal.blocked(table) || !net.tfminecraft.denareconomy.api.Denars.taxReady()) return; if (table.live()) { return; } @@ -1654,17 +1783,20 @@ public void beginSession(Table table) { 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); + var token=net.tfminecraft.games.ownership.CharacterAccess.capture(active); + var current = token == null ? null : new net.tfminecraft.tlibs.character.CharacterRef(token.account(),token.character()); 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(); + save(table); + if (table.ownershipHeld() || table.moneyBlocked()) return; Game game = gameOf(table); if (game != null) { game.onSessionStart(table); } + publishChatActors(); } public void tryBeginSession(Table table) { @@ -1877,13 +2009,13 @@ 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 (table.ownershipHeld() || table.estatePending() || 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()); + if (table.ownerGuildId() != null && !table.ownerGuildId().isBlank()) return GuildTables.leadsOwner(table, player); + return net.tfminecraft.games.ownership.OwnershipKind.of(table) == net.tfminecraft.games.ownership.OwnershipKind.PRIVATE + && net.tfminecraft.games.ownership.CharacterAccess.owns(table, player); } private void refreshGuildAutoIdle(String guildId) { @@ -1936,6 +2068,7 @@ public void persistHouseChange(Table table) { } public void applyHouse(Table table, TableHouse house) { + if(!house.matchesOwner(table) || table.ownershipHeld() || table.estatePending()) return; String wasGuild = table.ownerGuildId(); UUID wasIncarnation = table.ownerGuildIncarnation(); house.apply(table); @@ -1973,6 +2106,7 @@ private boolean tryOpenHouseOptions(Table table, Player player) { private static void applyHouseData(Table table, TableData data) { TableLayout layout = Cache.layoutOf(table.getGameId()); table.setOwnerPlayer(playerId(data.ownerPlayer)); + table.setOwnerCharacterId(playerId(data.ownerCharacterId)); table.setFloatOwner(playerId(data.floatOwner)); table.setDeckConsumed(!Boolean.FALSE.equals(data.deckConsumed)); table.setOwnerGuildId(data.ownerGuildId); @@ -1987,7 +2121,7 @@ private static void applyHouseData(Table table, TableData data) { if (data.autoDealer == null) { boolean yaml = layout != null && layout.autoDealer(); table.setAutoDealer(yaml); - table.setStaffMint(yaml); + table.setStaffMint(data.staffMint != null ? data.staffMint : yaml); if (layout != null) { table.setMinBet(layout.minBet()); table.setMaxBet(layout.maxBet()); @@ -2137,6 +2271,10 @@ public void applyPlayCall(Player player, String action) { return; } Table table = tableNearby(player); + var token=net.tfminecraft.games.ownership.CharacterAccess.capture(player); + var captured=table.wagerCharacters().get(player.getUniqueId()); + if(token==null || (captured==null ? !net.tfminecraft.games.ownership.CharacterAccess.owns(table,player) + : !token.account().equals(captured.ownerId()) || !token.character().equals(captured.characterId()))) return; Game game = gameOf(table); switch (action) { case "hit" -> game.onBetHit(table, player); @@ -2156,6 +2294,7 @@ private static boolean allowManualPotFlush(Table table, Player player) { /** Only asked of idle tables, where a table without a game is plain free play. */ private static boolean allowFreeDraw(Table table, Player player) { + if(table.ownershipHeld() || table.estatePending()) return false; Game game = gameOf(table); return game == null || game.allowFreeDraw(table, player); } @@ -2796,6 +2935,7 @@ private boolean tryPlaceChip(Player player, Location click) { /** True when the game will not take this player's money now, such as mid-hand from a stranger. */ private static boolean refuseStake(Table table, Player player) { + if(table.ownershipHeld() || table.estatePending()) return true; Game game = gameOf(table); if (game == null || game.allowStake(table, player)) { return false; @@ -4464,15 +4604,16 @@ 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))); + ownershipStore().save(table.getId(), net.tfminecraft.games.ownership.RawJson.JSON.toJsonTree(toData(table)).getAsJsonObject()); } private void save(Table table) { File file = tableFile(table.getId()); TableData data = toData(table); try { - net.tfminecraft.games.persistence.DurableFiles.write(file.toPath(), GSON.toJson(data)); + ownershipStore().save(table.getId(), net.tfminecraft.games.ownership.RawJson.JSON.toJsonTree(data).getAsJsonObject()); } catch (IOException ex) { + table.setOwnershipHeld(true);table.setMoneyBlocked(true); Games.plugin.getLogger().warning("[Games] Failed to save table " + table.getId() + ": " + ex.getMessage()); } } @@ -4524,6 +4665,10 @@ private static TableData toData(Table table) { } } data.ownerPlayer = table.ownerPlayer() != null ? table.ownerPlayer().toString() : null; + data.ownerCharacterId = table.ownerCharacterId() == null ? null : table.ownerCharacterId().toString(); + data.ownerIdentityVersion = 2; + data.ownershipLive = table.live(); + data.ownershipPaying = table.isPaying(); data.ownerGuildId = table.ownerGuildId(); data.ownerGuildIncarnation = table.ownerGuildIncarnation() == null ? null : table.ownerGuildIncarnation().toString(); data.ownerGuildMigrated = true; @@ -4764,12 +4909,14 @@ private static final class PlaceArm { private final long expiresAt; private final boolean requireDeck; private final TableHouse house; + private final net.tfminecraft.games.ownership.OwnerSession token; - private PlaceArm(String gameId, long expiresAt, boolean requireDeck, TableHouse house) { + private PlaceArm(String gameId, long expiresAt, boolean requireDeck, TableHouse house, net.tfminecraft.games.ownership.OwnerSession token) { this.gameId = gameId; this.expiresAt = expiresAt; this.requireDeck = requireDeck; this.house = house; + this.token = token; } } @@ -4803,6 +4950,9 @@ static final class TableData { List ledger = new ArrayList<>(); List piles = new ArrayList<>(); String ownerPlayer; + String ownerCharacterId; + Integer ownerIdentityVersion; + Boolean ownershipLive, ownershipPaying; String ownerGuildId; String ownerGuildIncarnation; Boolean ownerGuildMigrated; diff --git a/src/main/java/net/tfminecraft/games/wager/MoneyTx.java b/src/main/java/net/tfminecraft/games/wager/MoneyTx.java index 6f54335..ef09fd9 100644 --- a/src/main/java/net/tfminecraft/games/wager/MoneyTx.java +++ b/src/main/java/net/tfminecraft/games/wager/MoneyTx.java @@ -173,7 +173,7 @@ public TxResult commit() { if (table == null || host == null) { return TxResult.failed(TxResult.Reason.NO_TABLE, 0, 0); } - if (table.moneyBlocked() || (WagerJournal.blocked(table) && !WagerJournal.retryable(table))) + if (table.ownershipHeld() || table.moneyBlocked() || (WagerJournal.blocked(table) && !WagerJournal.retryable(table))) return TxResult.failed(TxResult.Reason.BANK_SHORT, 0, 0); if (legs.isEmpty()) { return TxResult.nothing(); @@ -181,6 +181,8 @@ public TxResult commit() { List planned = new ArrayList<>(); Map copies = new IdentityHashMap<>(); Map sources = new java.util.HashMap<>(); + Map rooms = new java.util.HashMap<>(); + boolean sourceChecked = false; for (Leg leg : legs) { String sourceKey = leg.from instanceof BucketAccount ? "bucket:" + leg.from.feltOwner() : leg.from instanceof PlayerAccount ? "player:" + leg.from.flightTarget() @@ -213,14 +215,23 @@ public TxResult commit() { if (!leg.to.canAccept(plan.denars())) { return TxResult.failed(TxResult.Reason.BANK_SHORT, want, 0); } + if (!sourceChecked) { + if (!table.ownershipWriteReady()) return TxResult.failed(TxResult.Reason.BANK_SHORT, want, 0); + sourceChecked = true; + } // 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()))) { + if (expected == null || (recipient.online() && !expected.equals(recipient.payeeCharacter()))) { WagerJournal.quarantine(table, "Payout character changed: owner=" + recipient.flightTarget() + ", expected=" + expected); return TxResult.failed(TxResult.Reason.PLAYER_SHORT, plan.denars(), 0); } + if (recipient.online() && recipient.outgoing() && !recipient.reserve(plan.preview(), rooms)) { + WagerJournal.quarantine(table, "Outgoing character cannot hold payout: owner=" + + recipient.flightTarget() + ", character=" + expected + ", value=" + plan.denars()); + return TxResult.failed(TxResult.Reason.PLAYER_SHORT, plan.denars(), 0); + } } if (leg.pool != null) { leg.pool[0] -= plan.denars(); diff --git a/src/main/java/net/tfminecraft/games/wager/OutgoingRefund.java b/src/main/java/net/tfminecraft/games/wager/OutgoingRefund.java new file mode 100644 index 0000000..97c82d8 --- /dev/null +++ b/src/main/java/net/tfminecraft/games/wager/OutgoingRefund.java @@ -0,0 +1,38 @@ +package net.tfminecraft.games.wager; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +/** The end event runs after session removal, while the outgoing body is still loaded. */ +public final class OutgoingRefund { + private record Scope(Player player, CharacterSession session) {} + private static final Map OPEN = new HashMap<>(); + + private OutgoingRefund() {} + + public static void during(Player player, CharacterSession ending, Runnable cleanup) { + if (!Bukkit.isPrimaryThread()) throw new IllegalStateException("Outgoing refunds require the main thread"); + UUID account = ending.ref().ownerId(); + boolean open = account.equals(player.getUniqueId()) && Bukkit.getPlayer(account) == player + && player.isOnline() && !player.isDead() + && !Characters.isCurrent(ending) && Characters.currentSession(player).isEmpty() + && OPEN.putIfAbsent(account, new Scope(player, ending)) == null; + try { cleanup.run(); } + finally { if (open) OPEN.remove(account); } + } + + static CharacterRef recipient(Player player) { + if (!Bukkit.isPrimaryThread()) return null; + Scope scope = player == null ? null : OPEN.get(player.getUniqueId()); + if (scope == null || scope.player() != player || !player.isOnline() || player.isDead() + || Characters.isCurrent(scope.session()) || Characters.currentSession(player).isPresent()) + return null; + return scope.session().ref(); + } +} diff --git a/src/main/java/net/tfminecraft/games/wager/PlayerAccount.java b/src/main/java/net/tfminecraft/games/wager/PlayerAccount.java index 36b1805..e69409a 100644 --- a/src/main/java/net/tfminecraft/games/wager/PlayerAccount.java +++ b/src/main/java/net/tfminecraft/games/wager/PlayerAccount.java @@ -45,6 +45,43 @@ net.tfminecraft.tlibs.character.CharacterRef character() { boolean online() { return player != null && player.isOnline(); } + net.tfminecraft.tlibs.character.CharacterRef payeeCharacter() { + var current = character(); + return current != null ? current : OutgoingRefund.recipient(player); + } + + boolean outgoing() { return character() == null && OutgoingRefund.recipient(player) != null; } + + /** Reserve all recipient legs against one detached storage inventory before anything moves. */ + boolean reserve(List stakes, java.util.Map rooms) { + ItemStack[] room = rooms.computeIfAbsent(id, ignored -> { + ItemStack[] contents = player.getInventory().getStorageContents().clone(); + for (int i = 0; i < contents.length; i++) + if (contents[i] != null) contents[i] = contents[i].clone(); + return contents; + }); + for (Stake stake : stakes) { + int left = stake.count(); + int max = Math.max(1, stake.item().getMaxStackSize()); + for (ItemStack slot : room) { + if (slot == null || slot.getType().isAir() || !slot.isSimilar(stake.item())) continue; + int add = Math.min(left, Math.max(0, max - slot.getAmount())); + slot.setAmount(slot.getAmount() + add); + left -= add; + } + for (int i = 0; i < room.length && left > 0; i++) { + if (room[i] != null && !room[i].getType().isAir()) continue; + ItemStack placed = stake.item().clone(); + int add = Math.min(left, max); + placed.setAmount(add); + room[i] = placed; + left -= add; + } + if (left > 0) return false; + } + return true; + } + Object snapshot() { java.util.List state = new java.util.ArrayList<>(); state.add(character()); @@ -217,6 +254,9 @@ private void giveItems(Stake stake) { /** Into the player's inventory, with whatever does not fit dropped at their feet. */ private void give(ItemStack items) { for (ItemStack rest : player.getInventory().addItem(items).values()) { + if (outgoing()) net.tfminecraft.games.Games.plugin.getLogger().severe( + "[Games] Outgoing refund exceeded reserved inventory: owner=" + id + + ", character=" + payeeCharacter()); drop(rest); } } diff --git a/src/main/java/net/tfminecraft/games/wager/WagerJournal.java b/src/main/java/net/tfminecraft/games/wager/WagerJournal.java index fee2f47..acc3243 100644 --- a/src/main/java/net/tfminecraft/games/wager/WagerJournal.java +++ b/src/main/java/net/tfminecraft/games/wager/WagerJournal.java @@ -24,7 +24,7 @@ record Movement(String from, String to, int value, List items, CitizenTa 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 Gson JSON = new GsonBuilder().serializeNulls().setPrettyPrinting().create(); private static final Map ACTIVE = new HashMap<>(); private WagerJournal() {} @@ -38,6 +38,9 @@ private static Path file(Table table) { public static boolean blocked(Table table) { return Games.plugin != null && Games.plugin.getDataFolder() != null && Files.exists(file(table)); } + public static boolean active(Table table) { + Intent intent=ACTIVE.get(file(table));return intent!=null && ("prepared".equals(intent.state()) || "applying".equals(intent.state())); + } public static void quarantine(Table table, String reason) { table.setMoneyBlocked(true); diff --git a/src/test/java/net/tfminecraft/games/GamesLifecycleTest.java b/src/test/java/net/tfminecraft/games/GamesLifecycleTest.java index 6ced6d6..debf53e 100644 --- a/src/test/java/net/tfminecraft/games/GamesLifecycleTest.java +++ b/src/test/java/net/tfminecraft/games/GamesLifecycleTest.java @@ -36,6 +36,7 @@ void firstEnableRecreatesMissingPluginDataDirectoryAndInstallsAllDefaults() thro MockBukkit.getMock().getPluginManager().enablePlugin(plugin); assertTrue(plugin.isEnabled()); assertTrue(Files.isDirectory(folder.resolve("Data/tables"))); + assertTrue(Files.exists(folder.resolve("ownership-active"))); for (String name : List.of("config.yml", "messages.yml", "cards.yml", "games.yml", "help.yml")) { assertTrue(Files.size(folder.resolve(name)) > 0, name); } @@ -94,7 +95,7 @@ void restartKeepsSavedTablesAndExistingDefaults() throws Exception { } @Test - void dataFolderBlockedByAFileReportsEachDefaultThatCannotBeInstalled() throws Exception { + void dataFolderBlockedByAFileCannotStartWithoutTheDurableRecoveryMarker() throws Exception { withLoadedPlugin(true, (plugin, manager, records) -> { var folder = plugin.getDataFolder().toPath(); Files.createDirectories(folder.getParent()); @@ -102,23 +103,28 @@ void dataFolderBlockedByAFileReportsEachDefaultThatCannotBeInstalled() throws Ex Files.writeString(folder, "not a directory"); try { MockBukkit.getMock().getPluginManager().enablePlugin(plugin); - assertTrue(plugin.isEnabled()); - List severe = records.stream() - .filter(record -> record.getLevel() == java.util.logging.Level.SEVERE) - .map(LogRecord::getMessage).toList(); - for (String name : List.of("config.yml", "messages.yml", "cards.yml", "games.yml", "help.yml")) { - assertTrue(severe.stream().anyMatch(message -> - message.startsWith("Failed to copy default resource " + name + ": ")), name); - } + assertFalse(plugin.isEnabled()); assertTrue(records.stream().map(LogRecord::getMessage) - .anyMatch("Games loaded with config errors."::equals)); - verify(manager).loadAll(); + .anyMatch(message->message.startsWith("Could not establish the ownership recovery marker: "))); + verify(manager,never()).loadAll(); } finally { Files.deleteIfExists(folder); } }); } + @Test + void cleanStopRemovesTheMarkerWhileUnresolvedMoneyRetainsIt() throws Exception { + withLoadedPlugin(true, (plugin, manager, records) -> { + MockBukkit.getMock().getPluginManager().enablePlugin(plugin); + var marker=plugin.getDataFolder().toPath().resolve("ownership-active");assertTrue(Files.exists(marker)); + MockBukkit.getMock().getPluginManager().disablePlugin(plugin);assertFalse(Files.exists(marker)); + MockBukkit.getMock().getPluginManager().enablePlugin(plugin); + var table=mock(net.tfminecraft.games.table.Table.class);when(table.moneyBlocked()).thenReturn(true);when(manager.tables()).thenReturn(List.of(table)); + MockBukkit.getMock().getPluginManager().disablePlugin(plugin);assertTrue(Files.exists(marker)); + }); + } + @Test void incompletePluginDescriptorReportsBothMissingCommandsAndStillStartsTableServices() throws Exception { withLoadedPlugin(false, (plugin, manager, records) -> { diff --git a/src/test/java/net/tfminecraft/games/game/FreePlayGameTest.java b/src/test/java/net/tfminecraft/games/game/FreePlayGameTest.java index 05b4b25..09080e5 100644 --- a/src/test/java/net/tfminecraft/games/game/FreePlayGameTest.java +++ b/src/test/java/net/tfminecraft/games/game/FreePlayGameTest.java @@ -46,14 +46,20 @@ void onlyTheTablesHostMayPayTheWholeFeltOutAndOnlyBetweenGames() { when(host.getUniqueId()).thenReturn(owner); when(shoe.getUniqueId()).thenReturn(dealer); when(player.getUniqueId()).thenReturn(UUID.randomUUID()); - table.setOwnerPlayer(owner); - table.setDealerId(dealer); + when(host.isOnline()).thenReturn(true);when(shoe.isOnline()).thenReturn(true); + table.setOwnerPlayer(owner);table.setOwnerCharacterId(net.tfminecraft.games.support.TestCharacters.ref(host).characterId()); + table.setDealerId(dealer);table.wagerCharacters().put(dealer,net.tfminecraft.games.support.TestCharacters.ref(shoe)); + try(var characters=net.tfminecraft.games.support.TestCharacters.mockSessions(); + var bukkit=mockStatic(org.bukkit.Bukkit.class,CALLS_REAL_METHODS)) { + bukkit.when(()->org.bukkit.Bukkit.getPlayer(owner)).thenReturn(host); + bukkit.when(()->org.bukkit.Bukkit.getPlayer(dealer)).thenReturn(shoe); assertFalse(game.allowManualPotFlush(null, host)); assertTrue(game.allowManualPotFlush(table, host), "the owner may pay out their own table"); assertTrue(game.allowManualPotFlush(table, shoe), "so may whoever holds the shoe"); assertFalse(game.allowManualPotFlush(table, player), "a visitor may not take everyone's stakes"); table.startSession(); assertFalse(game.allowManualPotFlush(table, host), "not even the owner, once a game is under way"); + } } @Test diff --git a/src/test/java/net/tfminecraft/games/gui/GameSelectGuiTest.java b/src/test/java/net/tfminecraft/games/gui/GameSelectGuiTest.java index a618cb6..1a1d8c1 100644 --- a/src/test/java/net/tfminecraft/games/gui/GameSelectGuiTest.java +++ b/src/test/java/net/tfminecraft/games/gui/GameSelectGuiTest.java @@ -38,6 +38,7 @@ class GameSelectGuiTest { private ItemAPI items; private MockedStatic managers; private MockedStatic libs; + private MockedStatic characters; @BeforeEach void setUp() { @@ -47,6 +48,7 @@ void setUp() { when(Games.plugin.namespace()).thenReturn("games"); when(Games.plugin.isEnabled()).thenReturn(true); player = MockBukkit.getMock().addPlayer(); + characters = net.tfminecraft.games.support.TestCharacters.mockSessions(); manager = mock(TableManager.class); managers = mockStatic(TableManager.class); managers.when(TableManager::get).thenReturn(manager); @@ -58,6 +60,7 @@ void setUp() { @AfterEach void tearDown() { + if(characters != null) characters.close(); libs.close(); managers.close(); Games.plugin = previousPlugin; diff --git a/src/test/java/net/tfminecraft/games/gui/TableOptionsGuiTest.java b/src/test/java/net/tfminecraft/games/gui/TableOptionsGuiTest.java index a3f2e6a..a827f35 100644 --- a/src/test/java/net/tfminecraft/games/gui/TableOptionsGuiTest.java +++ b/src/test/java/net/tfminecraft/games/gui/TableOptionsGuiTest.java @@ -50,10 +50,13 @@ class TableOptionsGuiTest { private MockedStatic managers; private MockedStatic guilds; + private org.mockito.MockedStatic characters; + @BeforeEach void setUp() { server = MockBukkit.getMock(); player = server.addPlayer(); + characters = net.tfminecraft.games.support.TestCharacters.mockSessions(); player.setOp(false); previousPlugin = Games.plugin; previousLayouts = new HashMap<>(Cache.tableLayouts); @@ -76,6 +79,7 @@ void setUp() { @AfterEach void tearDown() { + if(characters != null) characters.close(); TableOptionsGui.INSTANCE.onQuit(new PlayerQuitEvent(player, "")); server.getScheduler().cancelTasks(Games.plugin); guilds.close(); @@ -292,6 +296,28 @@ void staleOrUnauthorizedEditsNeverApplyChanges(String reason) { player.nextMessage()); } + @Test + void missedCharacterEndCannotAuthorizeAnOldMenuOrQueuedChat() { + TableOptionsGui.openPlace(player,false,null);TableOptionsHolder old=holder();click(12); + AsyncPlayerChatEvent queued=chatEvent("25");TableOptionsGui.INSTANCE.onChat(queued);assertTrue(queued.isCancelled()); + var b=new net.tfminecraft.tlibs.character.CharacterRef(player.getUniqueId(),UUID.randomUUID()); + characters.when(()->net.tfminecraft.tlibs.character.Characters.currentSession(player)).thenReturn(java.util.Optional.of(new net.tfminecraft.tlibs.character.CharacterSession(b,2))); + characters.when(()->net.tfminecraft.tlibs.character.Characters.info(b.characterId())).thenReturn(java.util.Optional.of(new net.tfminecraft.tlibs.character.CharacterInfo(b,"B",net.tfminecraft.tlibs.character.CharacterStatus.ALIVE,null,java.util.Set.of(),0,null))); + server.getScheduler().performTicks(1);assertEquals(5,old.house().minBet());verify(manager,never()).armPlace(any(),anyString(),anyBoolean(),any()); + player.openInventory(old.getInventory());click(16);verify(manager,never()).armPlace(any(),anyString(),anyBoolean(),any()); + } + + @Test + void currentStaffSessionCannotApplyAnOldOwnerSnapshotOrQueuedChatAfterEstateMovement() { + staff();Table table=editableTable();table.setOwnerCharacterId(UUID.randomUUID());TableOptionsGui.openEdit(player,table); + TableOptionsHolder old=holder();click(12);AsyncPlayerChatEvent queued=chatEvent("25");TableOptionsGui.INSTANCE.onChat(queued); + table.setOwnerPlayer(UUID.randomUUID());table.setOwnerCharacterId(UUID.randomUUID());server.getScheduler().performTicks(1); + assertEquals(5,old.house().minBet());assertEquals(5,table.minBet());verify(manager,never()).applyHouse(any(),any()); + player.openInventory(old.getInventory());click(16);verify(manager,never()).applyHouse(any(),any()); + UUID formerAccount=old.house().ownerPlayer(),formerCharacter=old.house().ownerCharacterId();table.setOwnerPlayer(formerAccount);table.setOwnerCharacterId(formerCharacter); + player.openInventory(old.getInventory());click(16);verify(manager,never()).applyHouse(any(),any()); + } + @Test void cancelDiscardsEditedOptionsAndNeverAppliesOrArms() { Table table = editableTable(); diff --git a/src/test/java/net/tfminecraft/games/guild/GuildTableMigrationTest.java b/src/test/java/net/tfminecraft/games/guild/GuildTableMigrationTest.java index e25f710..5920d61 100644 --- a/src/test/java/net/tfminecraft/games/guild/GuildTableMigrationTest.java +++ b/src/test/java/net/tfminecraft/games/guild/GuildTableMigrationTest.java @@ -99,4 +99,13 @@ private static GuildTables.Lookup found(UUID incarnation) { assertFalse(Files.exists(data.resolve("unused"))); } } + @Test void nullableLegacyFlagMigratesWithExactBackupAndUnknownNulls() throws Exception { + Path table=source();String before="{\"ownerGuildId\":\"guild\",\"ownerGuildMigrated\":null,\"houseFloat\":17,\"future\":null}";Files.writeString(table,before); + try(var guilds=mockStatic(GuildTables.class)) { + guilds.when(GuildTables::ready).thenReturn(true);guilds.when(()->GuildTables.resolveIncarnation("guild")).thenReturn(found(UUID.randomUUID())); + assertTrue(GuildTableMigration.prepare(table,data.resolve("migration"))); + assertEquals(before,Files.readString(data.resolve("migration/backup/table.json"))); + var after=net.tfminecraft.games.ownership.RawJson.read(table);assertTrue(after.get("ownerGuildMigrated").getAsBoolean());assertTrue(after.has("future"));assertTrue(after.get("future").isJsonNull());assertEquals(17,after.get("houseFloat").getAsInt()); + } + } } diff --git a/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java b/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java index 32cabc9..1108ea1 100644 --- a/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java +++ b/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java @@ -37,6 +37,7 @@ class GuildTablesTest { private Plugin integration; private Guild guild; private Player player; + private MockedStatic characters; private final List tables = new ArrayList<>(); @BeforeEach @@ -67,6 +68,8 @@ void setUp() throws ReflectiveOperationException { player = mock(Player.class); when(player.getName()).thenReturn("Alice"); when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.isOnline()).thenReturn(true); + characters=net.tfminecraft.games.support.TestCharacters.mockSessions(); factions.when(() -> FactionManager.getGuildByMember(player)).thenReturn(guild); factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(guild); TableManager manager = mock(TableManager.class); @@ -77,6 +80,7 @@ void setUp() throws ReflectiveOperationException { @AfterEach void closeMocks() { + if (characters != null) characters.close(); if (managers != null) managers.close(); if (api != null) api.close(); if (factions != null) factions.close(); @@ -230,6 +234,7 @@ void dealingRequiresMembershipPersonalOwnershipOrExplicitStaffPermission() { personal.setOwnerPlayer(UUID.randomUUID()); assertFalse(GuildTables.mayDeal(personal, player)); personal.setOwnerPlayer(player.getUniqueId()); + personal.setOwnerCharacterId(net.tfminecraft.games.support.TestCharacters.ref(player).characterId()); assertTrue(GuildTables.mayDeal(personal, player)); when(player.hasPermission(TableHouse.STAFF_PERM)).thenReturn(true); assertTrue(GuildTables.mayDeal(table("other"), player)); @@ -255,6 +260,7 @@ void onlyTheLeaderOfTheOwningGuildLeadsItsTable() { void blankSavedGuildFallsBackToPersonalOwnershipAndDeletedGuildHasNoMembers() { Table blank = table(" "); blank.setOwnerPlayer(player.getUniqueId()); + blank.setOwnerCharacterId(net.tfminecraft.games.support.TestCharacters.ref(player).characterId()); assertTrue(GuildTables.mayDeal(blank, player)); assertFalse(GuildTables.mayDeal(table("deleted"), player)); } diff --git a/src/test/java/net/tfminecraft/games/ownership/CharacterAccessTest.java b/src/test/java/net/tfminecraft/games/ownership/CharacterAccessTest.java new file mode 100644 index 0000000..c6ba4d7 --- /dev/null +++ b/src/test/java/net/tfminecraft/games/ownership/CharacterAccessTest.java @@ -0,0 +1,92 @@ +package net.tfminecraft.games.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import net.tfminecraft.games.table.Table; +import net.tfminecraft.tlibs.character.*; + +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; + +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; + +class CharacterAccessTest { + @Test + void sameAccountCharactersAndRenewedSessionsNeverSharePrivateAuthority() { + var player = MockBukkit.getMock().addPlayer(); + UUID account = player.getUniqueId(); + var a = new CharacterRef(account, UUID.randomUUID()); + var b = new CharacterRef(account, UUID.randomUUID()); + var current = new AtomicReference<>(new CharacterSession(a, 1)); + Table table = mock(Table.class); + when(table.ownerPlayer()).thenReturn(account); + when(table.ownerCharacterId()).thenReturn(a.characterId()); + try (var characters = mockStatic(Characters.class)) { + characters + .when(() -> Characters.currentSession(player)) + .thenAnswer(call -> Optional.of(current.get())); + characters + .when(() -> Characters.info(any())) + .thenAnswer( + call -> + Optional.of( + new CharacterInfo( + current.get().ref(), + "Fixture", + CharacterStatus.ALIVE, + null, + Set.of(), + 0, + null))); + characters.when(() -> Characters.isCurrent(any())).thenReturn(true); + assertTrue(CharacterAccess.available()); + assertTrue(CharacterAccess.owns(table, player)); + OwnerSession old = CharacterAccess.capture(player); + current.set(new CharacterSession(b, 2)); + assertFalse(CharacterAccess.owns(table, player)); + assertFalse(CharacterAccess.current(old)); + current.set(new CharacterSession(a, 3)); + assertTrue(CharacterAccess.owns(table, player)); + assertFalse(CharacterAccess.current(old)); + when(table.ownershipHeld()).thenReturn(true); + assertFalse(CharacterAccess.owns(table, player)); + } + } + + @Test + void wrongAccountStaleDeadAndMismatchedInfoProvidersFailBeforePromptCapture() { + var player = MockBukkit.getMock().addPlayer(); + var ref = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + var token = new AtomicReference<>(new CharacterSession(ref, 1)); + var info = + new AtomicReference<>( + new CharacterInfo( + ref, "Fixture", CharacterStatus.ALIVE, null, Set.of(), 0, null)); + try (var characters = mockStatic(Characters.class)) { + characters + .when(() -> Characters.currentSession(player)) + .thenAnswer(call -> Optional.of(token.get())); + characters + .when(() -> Characters.info(any())) + .thenAnswer(call -> Optional.of(info.get())); + characters.when(() -> Characters.isCurrent(any())).thenReturn(true); + assertNull(CharacterAccess.capture(player)); + var own = new CharacterRef(player.getUniqueId(), ref.characterId()); + token.set(new CharacterSession(own, 1)); + assertNull(CharacterAccess.capture(player)); + info.set( + new CharacterInfo( + own, "Fixture", CharacterStatus.DEAD, null, Set.of(), 0, null)); + assertNull(CharacterAccess.capture(player)); + info.set( + new CharacterInfo( + own, "Fixture", CharacterStatus.ALIVE, null, Set.of(), 0, null)); + characters.when(() -> Characters.isCurrent(any())).thenReturn(false); + assertNull(CharacterAccess.capture(player)); + characters.when(() -> Characters.isCurrent(any())).thenReturn(true); + assertNotNull(CharacterAccess.capture(player)); + } + } +} diff --git a/src/test/java/net/tfminecraft/games/ownership/OwnershipMigrationTest.java b/src/test/java/net/tfminecraft/games/ownership/OwnershipMigrationTest.java new file mode 100644 index 0000000..9546c9d --- /dev/null +++ b/src/test/java/net/tfminecraft/games/ownership/OwnershipMigrationTest.java @@ -0,0 +1,251 @@ +package net.tfminecraft.games.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.*; + +import net.tfminecraft.games.persistence.DurableFiles; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.*; +import java.util.*; + +class OwnershipMigrationTest { + @TempDir Path root; + UUID account = UUID.randomUUID(), character = UUID.randomUUID(), id = UUID.randomUUID(); + + Path source() { + return root.resolve("table.json"); + } + + JsonObject original() throws IOException { + JsonObject row = + RawJson.parse( + "{\"unknown\":{\"null\":null,\"array\":[1,null,{\"future\":true}]},\"ledger\":[],\"houseFloat\":0}"); + row.addProperty("id", id.toString()); + row.addProperty("ownerPlayer", account.toString()); + return row; + } + + OwnershipMigration migration(java.util.function.Function> heir) { + return new OwnershipMigration(root.resolve("migrations"), () -> true, heir); + } + + @Test + void legacyEffectiveModeIsFrozenBeforePublicationWithoutRecapturingOwnerOrMoney() + throws Exception { + var layouts = net.tfminecraft.games.cache.Cache.tableLayouts; + var previous = + layouts.put( + "mode-fixture", + new net.tfminecraft.games.layout.TableLayout( + "cards", "fixture", "icon", 6, Map.of(), null, null, null, 0, false, + true, 5, 80, 10)); + try { + var raw = original(); + raw.addProperty("gameId", "mode-fixture"); + raw.addProperty("ownerIdentityVersion", 2); + raw.addProperty("ownerCharacterId", character.toString()); + raw.addProperty("houseFloat", 17); + String before = RawJson.JSON.toJson(raw); + Files.writeString(source(), before); + var mode = + new OwnershipMigration( + root.resolve("migrations"), + () -> false, + a -> { + throw new AssertionError("Existing character cannot be recaptured"); + }); + try (var writer = mockStatic(DurableFiles.class, CALLS_REAL_METHODS)) { + writer.when(() -> DurableFiles.write(eq(source()), anyString())) + .thenThrow(new IOException("mode publication cut")); + assertThrows(IOException.class, () -> mode.prepare(source())); + } + layouts.put( + "mode-fixture", + new net.tfminecraft.games.layout.TableLayout( + "cards", "fixture", "icon", 6, Map.of(), null, null, null, 0)); + assertTrue(mode.prepare(source())); + var expected = raw.deepCopy(); + expected.addProperty("staffMint", true); + assertEquals(expected, RawJson.read(source())); + try (var files = Files.walk(root.resolve("migrations"))) { + assertEquals( + before, + Files.readString( + files.filter(path -> path.toString().endsWith(".original")) + .findFirst() + .orElseThrow())); + } + assertTrue(mode.prepare(source())); + assertEquals(expected, RawJson.read(source())); + assertEquals(OwnershipKind.STAFF, OwnershipKind.of(expected)); + } finally { + if (previous == null) layouts.remove("mode-fixture"); + else layouts.put("mode-fixture", previous); + } + } + + @Test + void reviewedIdentityChangesOnlyItsEnvelopeAndKeepsExactImmutableSource() throws Exception { + String before = RawJson.JSON.toJson(original()); + Files.writeString(source(), before); + assertTrue(migration(a -> Optional.of(character)).prepare(source())); + JsonObject after = RawJson.read(source()); + assertEquals(character, RawJson.uuid(after.get("ownerCharacterId"))); + assertEquals(account, RawJson.uuid(after.get("ownerPlayer"))); + assertEquals(original().get("unknown"), after.get("unknown")); + assertTrue(after.getAsJsonObject("unknown").has("null")); + try (var files = Files.walk(root.resolve("migrations"))) { + Path archive = + files.filter(p -> p.toString().endsWith(".original")).findFirst().orElseThrow(); + assertEquals(before, Files.readString(archive)); + } + assertTrue(migration(a -> Optional.of(UUID.randomUUID())).prepare(source())); + assertEquals(after, RawJson.read(source())); + } + + @Test + void missingHeirRetainsAccountThenLaterResolutionPreservesInterveningGameplay() + throws Exception { + Files.writeString(source(), RawJson.JSON.toJson(original())); + assertTrue(migration(a -> Optional.empty()).prepare(source())); + var held = RawJson.read(source()); + assertTrue(held.get("ownerCharacterId").isJsonNull()); + held.addProperty("ordinaryProgress", 77); + Files.writeString(source(), RawJson.JSON.toJson(held)); + assertTrue(migration(a -> Optional.of(character)).prepare(source())); + assertEquals(77, RawJson.read(source()).get("ordinaryProgress").getAsInt()); + assertEquals(character, RawJson.uuid(RawJson.read(source()).get("ownerCharacterId"))); + } + + @Test + void privateLegacyWaitsWithoutWritingWhileGuildAndStaffAuthorityRemainSeparate() + throws Exception { + var migration = + new OwnershipMigration( + root.resolve("migrations"), () -> false, a -> Optional.of(character)); + var raw = original(); + Files.writeString(source(), RawJson.JSON.toJson(raw)); + String before = Files.readString(source()); + assertFalse(migration.prepare(source())); + assertEquals(before, Files.readString(source())); + raw.addProperty("staffMint", true); + Files.writeString(source(), RawJson.JSON.toJson(raw)); + assertTrue(migration.prepare(source())); + assertEquals(OwnershipKind.STAFF, OwnershipKind.of(RawJson.read(source()))); + raw.addProperty("ownerGuildId", "guild"); + Files.writeString(source(), RawJson.JSON.toJson(raw)); + assertTrue(migration.prepare(source())); + assertEquals(OwnershipKind.GUILD, OwnershipKind.of(RawJson.read(source()))); + } + + @Test + void frozenRecipientReplaysAfterPublicationCutsWithoutResolvingAgain() throws Exception { + Files.writeString(source(), RawJson.JSON.toJson(original())); + try (var writer = mockStatic(DurableFiles.class, CALLS_REAL_METHODS)) { + writer.when(() -> DurableFiles.write(eq(source()), anyString())) + .thenThrow(new IOException("before main publication")); + assertThrows( + IOException.class, + () -> migration(a -> Optional.of(character)).prepare(source())); + } + assertTrue( + migration( + a -> { + throw new AssertionError( + "Frozen heir must not be resolved again"); + }) + .prepare(source())); + assertEquals(character, RawJson.uuid(RawJson.read(source()).get("ownerCharacterId"))); + } + + @Test + void changedSourceAfterFrozenPlanningIsHeldAndNeverOverwritten() throws Exception { + Files.writeString(source(), RawJson.JSON.toJson(original())); + try (var writer = mockStatic(DurableFiles.class, CALLS_REAL_METHODS)) { + writer.when(() -> DurableFiles.write(eq(source()), anyString())) + .thenThrow(new IOException("cut")); + assertThrows( + IOException.class, + () -> migration(a -> Optional.of(character)).prepare(source())); + } + var changed = original(); + changed.addProperty("progress", 55); + String current = RawJson.JSON.toJson(changed); + Files.writeString(source(), current); + assertThrows( + IOException.class, () -> migration(a -> Optional.of(character)).prepare(source())); + assertEquals(current, Files.readString(source())); + } + + @Test + void schemaAndDuplicateKeysRejectWithoutChangingBytes() throws Exception { + for (JsonElement version : + List.of( + new JsonPrimitive("2"), + new JsonPrimitive(1.5), + new JsonPrimitive(3), + JsonNull.INSTANCE)) { + var row = original(); + row.add("ownerIdentityVersion", version); + String bytes = RawJson.JSON.toJson(row); + Files.writeString(source(), bytes); + assertThrows( + IOException.class, + () -> migration(a -> Optional.of(character)).prepare(source())); + assertEquals(bytes, Files.readString(source())); + } + String duplicate = + "{\"ownerPlayer\":\"" + account + "\",\"unknown\":{\"null\":null,\"null\":7}}"; + Files.writeString(source(), duplicate); + assertThrows( + IOException.class, () -> migration(a -> Optional.of(character)).prepare(source())); + assertEquals(duplicate, Files.readString(source())); + var invalid = original(); + invalid.addProperty("ownerPlayer", "bad"); + Files.writeString(source(), RawJson.JSON.toJson(invalid)); + assertThrows( + IOException.class, () -> migration(a -> Optional.of(character)).prepare(source())); + } + + @Test + void completedArchiveStillRequiresIntegrityBeforeAnyLaterGameplaySave() throws Exception { + Files.writeString(source(), RawJson.JSON.toJson(original())); + assertTrue(migration(a -> Optional.of(character)).prepare(source())); + String current = Files.readString(source()); + try (var files = Files.walk(root.resolve("migrations"))) { + Files.writeString( + files.filter(p -> p.toString().endsWith(".original")).findFirst().orElseThrow(), + "corrupt archive"); + } + assertThrows( + IOException.class, () -> migration(a -> Optional.of(character)).prepare(source())); + assertEquals(current, Files.readString(source())); + } + + @Test + void invalidGuildAndFloatOwnershipNeverPublishOrNormalizeTheirSource() throws Exception { + for (String key : List.of("ownerGuildIncarnation", "floatOwner")) { + var row = original(); + row.addProperty(key, "invalid-owner"); + String bytes = RawJson.JSON.toJson(row); + Files.writeString(source(), bytes); + assertThrows( + IOException.class, + () -> migration(a -> Optional.of(character)).prepare(source())); + assertEquals(bytes, Files.readString(source())); + } + var ambiguous = original(); + ambiguous.addProperty("ownerGuildIncarnation", UUID.randomUUID().toString()); + String bytes = RawJson.JSON.toJson(ambiguous); + Files.writeString(source(), bytes); + assertThrows( + IOException.class, () -> migration(a -> Optional.of(character)).prepare(source())); + assertEquals(bytes, Files.readString(source())); + } +} diff --git a/src/test/java/net/tfminecraft/games/ownership/TableEstateTest.java b/src/test/java/net/tfminecraft/games/ownership/TableEstateTest.java new file mode 100644 index 0000000..6a797fa --- /dev/null +++ b/src/test/java/net/tfminecraft/games/ownership/TableEstateTest.java @@ -0,0 +1,667 @@ +package net.tfminecraft.games.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.*; + +import net.tfminecraft.games.Games; +import net.tfminecraft.games.table.*; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.estate.*; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +import java.nio.file.*; +import java.time.Instant; +import java.util.*; + +class TableEstateTest { + @TempDir Path data; + Games plugin, previous; + TableManager manager; + TableOwnershipStore store; + TableEstate estate; + MockedStatic characters; + CharacterRef from = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()), + to = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + UUID death = UUID.randomUUID(), tableId = UUID.randomUUID(); + Set pending = new HashSet<>(); + + @BeforeEach + void prepare() { + previous = Games.plugin; + plugin = mock(Games.class); + Games.plugin = plugin; + when(plugin.getDataFolder()).thenReturn(data.toFile()); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + manager = mock(TableManager.class); + store = new TableOwnershipStore(data); + when(manager.ownershipStore()).thenReturn(store); + when(manager.tables()).thenReturn(List.of()); + doAnswer( + call -> { + Collection ids = call.getArgument(0); + if (call.getArgument(1)) pending.addAll(ids); + else pending.removeAll(ids); + return null; + }) + .when(manager) + .estatePending(anyCollection(), anyBoolean()); + characters = mockStatic(CharacterAccess.class, CALLS_REAL_METHODS); + characters.when(CharacterAccess::heirReady).thenReturn(true); + estate = new TableEstate(plugin, manager); + estate.guard = d -> true; + } + + @AfterEach + void finish() { + characters.close(); + Games.plugin = previous; + } + + EstateDecision decision(long revision, Recipient property) { + return new EstateDecision( + death, + from, + Optional.of(Instant.EPOCH), + Instant.EPOCH, + Optional.of(to), + Optional.empty(), + Optional.empty(), + Recipient.FROZEN, + property, + Recipient.FROZEN, + Recipient.SENDER, + revision); + } + + JsonObject raw(UUID id) throws Exception { + JsonObject row = + RawJson.parse( + "{\"ownerIdentityVersion\":2,\"unknown\":{\"nullable\":null},\"world\":\"unloaded\",\"ledger\":[],\"piles\":[],\"houseFloat\":0,\"actives\":[],\"autoDealer\":false,\"staffMint\":false}"); + row.addProperty("id", id.toString()); + row.addProperty("ownerPlayer", from.ownerId().toString()); + row.addProperty("ownerCharacterId", from.characterId().toString()); + return row; + } + + Path write(JsonObject row) throws Exception { + Path path = data.resolve("Data/tables/" + row.get("id").getAsString() + ".json"); + Files.createDirectories(path.getParent()); + Files.writeString(path, RawJson.JSON.toJson(row)); + return path; + } + + Path journal(long revision) { + return data.resolve("estates/tables/" + death + "-" + revision + ".json"); + } + + @Test + void unloadedPrivatePropertyMovesOnceWithUnknownNullsAndWagerRefsPreserved() throws Exception { + JsonObject before = raw(tableId), refs = new JsonObject(); + refs.addProperty(from.ownerId().toString(), from.characterId().toString()); + before.add("wagerCharacters", refs); + Path file = write(before); + var first = decision(1, Recipient.character(to)); + Outcome result = estate.settle(first); + assertInstanceOf(Outcome.Done.class, result); + JsonObject expected = before.deepCopy(); + expected.addProperty("ownerPlayer", to.ownerId().toString()); + expected.addProperty("ownerCharacterId", to.characterId().toString()); + assertEquals(expected, RawJson.read(file)); + assertTrue(pending.isEmpty()); + assertEquals(result, estate.settle(first)); + var second = + decision( + 2, + Recipient.character( + new CharacterRef(UUID.randomUUID(), UUID.randomUUID()))); + assertEquals(result, estate.settle(second)); + estate = new TableEstate(plugin, manager); + estate.guard = d -> true; + estate.recover(); + assertEquals(result, estate.settle(second)); + assertEquals(Holdings.NONE, estate.describe(from.characterId())); + } + + @Test + void guildMetadataAndStaffMintRemainOutsidePrivateInheritanceAcrossAllModes() throws Exception { + JsonObject guild = raw(UUID.randomUUID()); + guild.addProperty("ownerGuildId", "guild"); + guild.addProperty("staffMint", true); + guild.addProperty("ownerGuildIncarnation", UUID.randomUUID().toString()); + JsonObject mint = raw(UUID.randomUUID()); + mint.addProperty("staffMint", true); + JsonObject auto = raw(tableId); + auto.addProperty("autoDealer", true); + Path guildFile = write(guild), mintFile = write(mint), privateFile = write(auto); + assertInstanceOf(Outcome.Done.class, estate.settle(decision(1, Recipient.character(to)))); + assertEquals(guild, RawJson.read(guildFile)); + assertEquals(mint, RawJson.read(mintFile)); + assertEquals( + to.characterId(), RawJson.uuid(RawJson.read(privateFile).get("ownerCharacterId"))); + } + + @Test + void providerReadinessAndUnresolvedSlotsCannotCreateAnEmptyReceipt() throws Exception { + var row = raw(tableId); + row.add("ownerCharacterId", JsonNull.INSTANCE); + write(row); + characters.when(CharacterAccess::heirReady).thenReturn(false); + assertInstanceOf(Outcome.Retry.class, estate.settle(decision(1, Recipient.character(to)))); + assertFalse(Files.exists(journal(1))); + characters.when(CharacterAccess::heirReady).thenReturn(true); + assertInstanceOf( + Outcome.Blocked.class, estate.settle(decision(1, Recipient.character(to)))); + assertFalse(Files.exists(journal(1))); + row.addProperty("ownerCharacterId", from.characterId().toString()); + write(row); + assertInstanceOf(Outcome.Done.class, estate.settle(decision(1, Recipient.character(to)))); + } + + @Test + void completedRestoredSourceBlocksCharacterWaitingAndFrozenRevisionsAcrossRestart() + throws Exception { + Path file = write(raw(tableId)); + assertInstanceOf(Outcome.Done.class, estate.settle(decision(1, Recipient.character(to)))); + Files.writeString(file, RawJson.JSON.toJson(raw(tableId))); + String source = Files.readString(file), receipt = Files.readString(journal(1)); + for (Recipient property : + List.of(Recipient.character(to), Recipient.WAITING, Recipient.FROZEN)) { + estate = new TableEstate(plugin, manager); + estate.guard = d -> true; + estate.recover(); + assertInstanceOf(Outcome.Blocked.class, estate.settle(decision(2, property))); + assertEquals(source, Files.readString(file)); + assertEquals(receipt, Files.readString(journal(1))); + assertFalse(Files.exists(journal(2))); + } + } + + @Test + void unsupportedAccountSenderAndSelfRecipientsBlockWithoutFictitiousMovesOrIntent() + throws Exception { + Path file = write(raw(tableId)); + String source = Files.readString(file); + assertInstanceOf( + Outcome.Blocked.class, estate.settle(decision(1, Recipient.character(from)))); + for (Recipient property : + List.of( + Recipient.account("char:" + to.characterId() + ":bank"), + Recipient.SENDER)) { + EstateDecision invalid = mock(EstateDecision.class); + when(invalid.property()).thenReturn(property); + when(invalid.testator()).thenReturn(from); + assertInstanceOf(Outcome.Blocked.class, estate.settle(invalid)); + assertEquals(source, Files.readString(file)); + assertFalse(Files.exists(journal(1))); + } + } + + @Test + void exactCurrentReceiptDecisionIsCheckedBeforeWaitingFrozenOrSelfReturns() throws Exception { + write(raw(tableId)); + assertInstanceOf(Outcome.Done.class, estate.settle(decision(1, Recipient.character(to)))); + String receipt = Files.readString(journal(1)); + for (Recipient property : + List.of(Recipient.WAITING, Recipient.FROZEN, Recipient.character(from))) { + assertInstanceOf(Outcome.Blocked.class, estate.settle(decision(1, property))); + assertEquals(receipt, Files.readString(journal(1))); + } + } + + @Test + void futureRevisionEvidenceBlocksBeforeObsoletingPlansOrChangingOwnership() throws Exception { + Path file = write(raw(tableId)); + Table live = mock(Table.class); + when(live.live()).thenReturn(true); + when(manager.table(tableId)).thenReturn(live); + assertInstanceOf(Outcome.Retry.class, estate.settle(decision(2, Recipient.character(to)))); + String intent = Files.readString(journal(2)), source = Files.readString(file); + assertInstanceOf( + Outcome.Blocked.class, estate.settle(decision(1, Recipient.character(to)))); + assertEquals(intent, Files.readString(journal(2))); + assertEquals(source, Files.readString(file)); + assertFalse(Files.exists(journal(1))); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"CHARACTER", "WAITING", "FROZEN"}) + void emptyNoncharacterCompletionBlocksSameRevisionGrowthAndAcceptsUntouchedNewerProperty( + String kind) throws Exception { + Recipient property = + kind.equals("CHARACTER") + ? Recipient.character(to) + : kind.equals("WAITING") ? Recipient.WAITING : Recipient.FROZEN; + var first = decision(1, property); + assertEquals(Outcome.done(Map.of("private-tables", 0), List.of()), estate.settle(first)); + String receipt = Files.readString(journal(1)); + var envelope = RawJson.read(journal(1)); + assertEquals( + kind, + envelope.has("propertyKind") + ? RawJson.string(envelope, "propertyKind") + : "CHARACTER"); + estate = new TableEstate(plugin, manager); + estate.guard = d -> true; + estate.recover(); + assertEquals(Outcome.done(Map.of("private-tables", 0), List.of()), estate.settle(first)); + assertEquals(receipt, Files.readString(journal(1))); + assertInstanceOf( + Outcome.Blocked.class, + estate.settle( + decision( + 1, + Recipient.character( + new CharacterRef(UUID.randomUUID(), UUID.randomUUID()))))); + assertEquals(receipt, Files.readString(journal(1))); + Path file = write(raw(tableId)); + String source = Files.readString(file); + assertInstanceOf(Outcome.Blocked.class, estate.settle(first)); + assertEquals(source, Files.readString(file)); + assertEquals(receipt, Files.readString(journal(1))); + assertInstanceOf(Outcome.Done.class, estate.settle(decision(2, Recipient.character(to)))); + assertEquals(to.characterId(), RawJson.uuid(RawJson.read(file).get("ownerCharacterId"))); + } + + @Test + void newlyResolvedOwnershipRequiresANewerRevisionAfterEmptyCompletion() throws Exception { + JsonObject row = raw(tableId); + row.addProperty("ownerCharacterId", UUID.randomUUID().toString()); + Path file = write(row); + assertInstanceOf(Outcome.Done.class, estate.settle(decision(1, Recipient.FROZEN))); + String receipt = Files.readString(journal(1)); + row.addProperty("ownerCharacterId", from.characterId().toString()); + write(row); + String source = Files.readString(file); + estate = new TableEstate(plugin, manager); + estate.guard = d -> true; + estate.recover(); + assertInstanceOf(Outcome.Blocked.class, estate.settle(decision(1, Recipient.FROZEN))); + assertEquals(source, Files.readString(file)); + assertEquals(receipt, Files.readString(journal(1))); + assertInstanceOf(Outcome.Done.class, estate.settle(decision(2, Recipient.character(to)))); + assertEquals(to.characterId(), RawJson.uuid(RawJson.read(file).get("ownerCharacterId"))); + } + + @Test + void untouchedRetainedPropertyTransfersOnlyOnNewRevisionWithCumulativeCompletionEvidence() + throws Exception { + Path file = write(raw(tableId)); + String source = Files.readString(file); + assertInstanceOf(Outcome.Done.class, estate.settle(decision(1, Recipient.WAITING))); + assertEquals(source, Files.readString(file)); + var held = RawJson.read(journal(1)); + assertEquals( + tableId, + RawJson.uuid(held.getAsJsonArray("retained").get(0).getAsJsonObject().get("id"))); + assertInstanceOf( + Outcome.Blocked.class, estate.settle(decision(1, Recipient.character(to)))); + assertEquals(source, Files.readString(file)); + assertEquals( + Outcome.done(Map.of("private-tables", 1), List.of(tableId.toString())), + estate.settle(decision(2, Recipient.character(to)))); + assertEquals(to.characterId(), RawJson.uuid(RawJson.read(file).get("ownerCharacterId"))); + assertInstanceOf(Outcome.Done.class, estate.settle(decision(3, Recipient.FROZEN))); + JsonObject completion = RawJson.read(journal(3)); + assertTrue(completion.getAsJsonArray("retained").isEmpty()); + assertEquals(1, completion.getAsJsonArray("moved").size()); + completion.add("moved", new JsonArray()); + Files.writeString(journal(3), RawJson.JSON.toJson(completion)); + String movedSource = Files.readString(file); + assertInstanceOf( + Outcome.Blocked.class, + estate.settle( + decision( + 4, + Recipient.character( + new CharacterRef(UUID.randomUUID(), UUID.randomUUID()))))); + assertEquals(movedSource, Files.readString(file)); + assertFalse(Files.exists(journal(4))); + } + + @Test + void guardedPublicationCutAfterRetainedCompletionReplaysItsExactCurrentSnapshot() + throws Exception { + Path file = write(raw(tableId)); + assertTrue(store.prepare(file)); + assertInstanceOf(Outcome.Done.class, estate.settle(decision(1, Recipient.FROZEN))); + var cut = new java.util.concurrent.atomic.AtomicBoolean(true); + try (var writer = + mockStatic( + net.tfminecraft.games.persistence.DurableFiles.class, CALLS_REAL_METHODS)) { + writer.when( + () -> + net.tfminecraft.games.persistence.DurableFiles.write( + eq(file), anyString())) + .thenAnswer( + call -> { + call.callRealMethod(); + if (cut.getAndSet(false)) + throw new java.io.IOException( + "retained-to-character acknowledgement cut"); + return null; + }); + assertInstanceOf( + Outcome.Retry.class, estate.settle(decision(2, Recipient.character(to)))); + assertTrue(store.publicationPending(tableId)); + assertInstanceOf( + Outcome.Done.class, estate.settle(decision(2, Recipient.character(to)))); + assertFalse(store.publicationPending(tableId)); + assertEquals( + to.characterId(), RawJson.uuid(RawJson.read(file).get("ownerCharacterId"))); + } + } + + @Test + void retainedReferenceAndCoherentlyEmptiedInventoryCorruptionCannotAuthorizeEffects() + throws Exception { + Path file = write(raw(tableId)); + assertInstanceOf(Outcome.Done.class, estate.settle(decision(1, Recipient.FROZEN))); + String receipt = Files.readString(journal(1)), source = Files.readString(file); + for (boolean clear : List.of(false, true)) { + JsonObject plan = RawJson.parse(receipt); + if (clear) { + plan.add("candidates", new JsonArray()); + plan.add("retained", new JsonArray()); + } else + plan.getAsJsonArray("retained") + .get(0) + .getAsJsonObject() + .addProperty("character", UUID.randomUUID().toString()); + Files.writeString(journal(1), RawJson.JSON.toJson(plan)); + assertInstanceOf( + Outcome.Blocked.class, + estate.settle( + decision( + clear ? 1 : 2, + clear ? Recipient.FROZEN : Recipient.character(to)))); + assertEquals(source, Files.readString(file)); + assertFalse(Files.exists(journal(2))); + } + } + + @Test + void unloadedLegacyModeMustBeFrozenAndItsClassificationCannotDriftWithLayout() + throws Exception { + var layouts = net.tfminecraft.games.cache.Cache.tableLayouts; + var previous = + layouts.put( + "estate-mode", + new net.tfminecraft.games.layout.TableLayout( + "cards", "fixture", "icon", 6, Map.of(), null, null, null, 0, false, + true, 5, 80, 10)); + try { + JsonObject legacy = raw(tableId); + legacy.addProperty("gameId", "estate-mode"); + legacy.remove("staffMint"); + legacy.add("autoDealer", JsonNull.INSTANCE); + legacy.addProperty("houseFloat", 17); + Path file = write(legacy); + String original = Files.readString(file); + assertEquals(OwnershipKind.STAFF, store.row(file).kind()); + assertInstanceOf( + Outcome.Retry.class, estate.settle(decision(1, Recipient.character(to)))); + assertFalse(Files.exists(journal(1))); + assertEquals(original, Files.readString(file)); + assertTrue(store.prepare(file)); + var frozen = RawJson.read(file); + assertTrue(frozen.get("staffMint").getAsBoolean()); + assertEquals(17, frozen.get("houseFloat").getAsInt()); + assertEquals(from.characterId(), RawJson.uuid(frozen.get("ownerCharacterId"))); + layouts.remove("estate-mode"); + assertEquals(OwnershipKind.STAFF, store.row(file).kind()); + assertEquals( + Outcome.done(Map.of("private-tables", 0), List.of()), + estate.settle(decision(1, Recipient.character(to)))); + assertEquals(frozen, RawJson.read(file)); + death = UUID.randomUUID(); + JsonObject privateAuto = raw(UUID.randomUUID()); + privateAuto.addProperty("gameId", "estate-mode"); + privateAuto.add("autoDealer", JsonNull.INSTANCE); + Path privateFile = write(privateAuto); + assertEquals(OwnershipKind.PRIVATE, store.row(privateFile).kind()); + assertInstanceOf( + Outcome.Done.class, estate.settle(decision(2, Recipient.character(to)))); + assertEquals( + to.characterId(), + RawJson.uuid(RawJson.read(privateFile).get("ownerCharacterId"))); + assertEquals(frozen, RawJson.read(file)); + } finally { + if (previous == null) layouts.remove("estate-mode"); + else layouts.put("estate-mode", previous); + } + } + + @Test + void receiptIdsAndHistoricalRecipientEnvelopesRejectIndependentCorruption() throws Exception { + Path file = write(raw(tableId)); + assertInstanceOf(Outcome.Done.class, estate.settle(decision(1, Recipient.character(to)))); + String receipt = Files.readString(journal(1)); + for (String defect : List.of("moved", "recipient", "revision")) { + JsonObject plan = RawJson.parse(receipt); + switch (defect) { + case "moved" -> plan.add("moved", new JsonArray()); + case "recipient" -> plan.addProperty("toCharacter", UUID.randomUUID().toString()); + default -> plan.addProperty("revision", 1.5); + } + Files.writeString(journal(1), RawJson.JSON.toJson(plan)); + Files.writeString(file, RawJson.JSON.toJson(raw(tableId))); + assertInstanceOf( + Outcome.Blocked.class, estate.settle(decision(2, Recipient.character(to)))); + assertEquals( + from.characterId(), RawJson.uuid(RawJson.read(file).get("ownerCharacterId"))); + assertFalse(Files.exists(journal(2))); + } + } + + @Test + void durablePendingSurvivesRestartWhileLiveWorkRetriesAndSavedMoneyBlocks() throws Exception { + JsonObject raw = raw(tableId); + Path file = write(raw); + Table live = mock(Table.class); + when(live.live()).thenReturn(true); + when(manager.table(tableId)).thenReturn(live); + assertInstanceOf(Outcome.Retry.class, estate.settle(decision(1, Recipient.character(to)))); + assertTrue(Files.exists(journal(1))); + assertTrue(pending.contains(tableId)); + pending.clear(); + estate = new TableEstate(plugin, manager); + estate.guard = d -> true; + estate.recover(); + assertTrue(pending.contains(tableId)); + assertEquals(raw, RawJson.read(file)); + when(manager.table(tableId)).thenReturn(null); + raw.addProperty("houseFloat", 17); + write(raw); + assertInstanceOf( + Outcome.Blocked.class, estate.settle(decision(1, Recipient.character(to)))); + assertEquals(17, RawJson.read(file).get("houseFloat").getAsInt()); + } + + @Test + void retainedWagerIntentAndUnloadedSavedSeatsCannotBeWaivedByOwnership() throws Exception { + JsonObject raw = raw(tableId); + Path file = write(raw); + Path wager = data.resolve("Data/wager-intents/" + tableId + ".json"); + Files.createDirectories(wager.getParent()); + Files.writeString(wager, "retained original money intent"); + assertInstanceOf( + Outcome.Blocked.class, estate.settle(decision(1, Recipient.character(to)))); + assertEquals("retained original money intent", Files.readString(wager)); + assertEquals(raw, RawJson.read(file)); + Files.delete(wager); + JsonArray seats = new JsonArray(); + seats.add(from.ownerId().toString()); + raw.add("actives", seats); + write(raw); + assertInstanceOf( + Outcome.Blocked.class, estate.settle(decision(1, Recipient.character(to)))); + assertEquals(raw, RawJson.read(file)); + } + + @Test + void malformedDuplicateAndExternalRawConflictsBlockWithoutWritingAnEstateOrSource() + throws Exception { + JsonObject raw = raw(tableId); + Path file = write(raw); + assertTrue(store.prepare(file)); + String original = Files.readString(file); + Files.writeString(file, "{\"ownerPlayer\":"); + String malformed = Files.readString(file); + assertInstanceOf( + Outcome.Blocked.class, estate.settle(decision(1, Recipient.character(to)))); + assertEquals(malformed, Files.readString(file)); + assertFalse(Files.exists(journal(1))); + Files.writeString(file, original); + Path duplicate = file.resolveSibling("duplicate.json"); + Files.writeString(duplicate, original); + assertInstanceOf( + Outcome.Blocked.class, estate.settle(decision(1, Recipient.character(to)))); + assertEquals(original, Files.readString(file)); + assertEquals(original, Files.readString(duplicate)); + assertFalse(Files.exists(journal(1))); + Files.delete(duplicate); + store = new TableOwnershipStore(data); + when(manager.ownershipStore()).thenReturn(store); + assertTrue(store.prepare(file)); + Files.writeString(file, original + " "); + String outside = Files.readString(file); + assertInstanceOf( + Outcome.Blocked.class, estate.settle(decision(1, Recipient.character(to)))); + assertEquals(outside, Files.readString(file)); + assertTrue(store.held(tableId)); + assertFalse(Files.exists(journal(1))); + } + + @Test + void currentIntentReflushesExactPublishedBytesAfterAnAcknowledgementFailure() throws Exception { + Path file = write(raw(tableId)); + assertTrue(store.prepare(file)); + var first = new java.util.concurrent.atomic.AtomicBoolean(true); + try (var writer = + mockStatic( + net.tfminecraft.games.persistence.DurableFiles.class, CALLS_REAL_METHODS)) { + writer.when( + () -> + net.tfminecraft.games.persistence.DurableFiles.write( + eq(file), anyString())) + .thenAnswer( + call -> { + call.callRealMethod(); + if (first.getAndSet(false)) + throw new java.io.IOException( + "after bytes before acknowledgement"); + return null; + }); + assertInstanceOf( + Outcome.Retry.class, estate.settle(decision(1, Recipient.character(to)))); + assertTrue(store.publicationPending(tableId)); + assertTrue(store.held(tableId)); + assertEquals( + to.characterId(), RawJson.uuid(RawJson.read(file).get("ownerCharacterId"))); + assertFalse(RawJson.read(journal(1)).get("complete").getAsBoolean()); + assertInstanceOf( + Outcome.Done.class, estate.settle(decision(1, Recipient.character(to)))); + assertFalse(store.publicationPending(tableId)); + assertFalse(store.held(tableId)); + assertEquals(1, RawJson.read(journal(1)).getAsJsonArray("moved").size()); + } + } + + @Test + void revivalReleasesOnlyAnUntouchedIntentWithExactReadyProviderEvidence() throws Exception { + Path file = write(raw(tableId)); + Table live = mock(Table.class); + when(live.live()).thenReturn(true); + when(manager.table(tableId)).thenReturn(live); + assertInstanceOf(Outcome.Retry.class, estate.settle(decision(1, Recipient.character(to)))); + String source = Files.readString(file); + estate = new TableEstate(plugin, manager); + estate.recover(); + estate.serviceReady = () -> true; + try (var identity = mockStatic(net.tfminecraft.tlibs.character.Characters.class)) { + estate.releaseCancelled(); + assertTrue(pending.contains(tableId)); + assertFalse(RawJson.read(journal(1)).get("complete").getAsBoolean()); + var alive = + new net.tfminecraft.tlibs.character.CharacterInfo( + from, + "fixture", + net.tfminecraft.tlibs.character.CharacterStatus.ALIVE, + null, + Set.of(), + 0, + null); + identity.when(() -> net.tfminecraft.tlibs.character.Characters.info(from.characterId())) + .thenReturn(Optional.of(alive)); + identity.when( + () -> + net.tfminecraft.tlibs.character.Characters.deathId( + from.characterId())) + .thenReturn(Optional.of(death)); + characters.when(CharacterAccess::heirReady).thenReturn(false); + estate.releaseCancelled(); + assertTrue(pending.contains(tableId)); + characters.when(CharacterAccess::heirReady).thenReturn(true); + estate.serviceReady = () -> false; + estate.releaseCancelled(); + assertTrue(pending.contains(tableId)); + estate.serviceReady = () -> true; + estate.releaseCancelled(); + assertTrue(pending.isEmpty()); + assertEquals(source, Files.readString(file)); + JsonObject receipt = RawJson.read(journal(1)); + assertTrue(receipt.get("obsolete").getAsBoolean()); + assertTrue(receipt.getAsJsonArray("moved").isEmpty()); + estate.recover(); + assertTrue(pending.isEmpty()); + } + } + + @Test + void revivalCannotReleaseAPartiallyPublishedFrozenPlan() throws Exception { + Path file = write(raw(tableId)); + var first = new java.util.concurrent.atomic.AtomicBoolean(true); + try (var writer = + mockStatic( + net.tfminecraft.games.persistence.DurableFiles.class, CALLS_REAL_METHODS)) { + writer.when( + () -> + net.tfminecraft.games.persistence.DurableFiles.write( + eq(file), anyString())) + .thenAnswer( + call -> { + call.callRealMethod(); + if (first.getAndSet(false)) + throw new java.io.IOException( + "publication acknowledgement cut"); + return null; + }); + assertInstanceOf( + Outcome.Retry.class, estate.settle(decision(1, Recipient.character(to)))); + } + String published = Files.readString(file); + estate.serviceReady = () -> true; + try (var identity = mockStatic(net.tfminecraft.tlibs.character.Characters.class)) { + var alive = + new net.tfminecraft.tlibs.character.CharacterInfo( + from, + "fixture", + net.tfminecraft.tlibs.character.CharacterStatus.ALIVE, + null, + Set.of(), + 0, + null); + identity.when(() -> net.tfminecraft.tlibs.character.Characters.info(from.characterId())) + .thenReturn(Optional.of(alive)); + estate.releaseCancelled(); + assertTrue(pending.contains(tableId)); + assertEquals(published, Files.readString(file)); + assertFalse(RawJson.read(journal(1)).get("complete").getAsBoolean()); + } + } +} diff --git a/src/test/java/net/tfminecraft/games/ownership/TableOwnershipStoreTest.java b/src/test/java/net/tfminecraft/games/ownership/TableOwnershipStoreTest.java new file mode 100644 index 0000000..c6b8614 --- /dev/null +++ b/src/test/java/net/tfminecraft/games/ownership/TableOwnershipStoreTest.java @@ -0,0 +1,123 @@ +package net.tfminecraft.games.ownership; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.*; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.*; +import java.util.*; + +class TableOwnershipStoreTest { + @TempDir Path data; + UUID id = UUID.randomUUID(), account = UUID.randomUUID(), character = UUID.randomUUID(); + + JsonObject row() throws Exception { + var row = + RawJson.parse( + "{\"unknown\":{\"nullable\":null},\"ledger\":[],\"world\":\"missing\",\"ownerIdentityVersion\":2}"); + row.addProperty("id", id.toString()); + row.addProperty("ownerPlayer", account.toString()); + row.addProperty("ownerCharacterId", character.toString()); + return row; + } + + Path write(JsonObject row) throws IOException { + Path path = data.resolve("Data/tables/imported.json"); + Files.createDirectories(path.getParent()); + Files.writeString(path, RawJson.JSON.toJson(row)); + return path; + } + + @Test + void failedIntegrityReadRemainsHeldInMemoryAfterReadAccessReturns() throws Exception { + Path path = write(row()); + var store = new TableOwnershipStore(data); + store.observe(path); + try (var files = org.mockito.Mockito.mockStatic(Files.class, org.mockito.Mockito.CALLS_REAL_METHODS)) { + files.when(() -> Files.readAllBytes(path)).thenThrow(new IOException("unreadable source")); + assertThrows(IOException.class, () -> store.check(id)); + } + assertTrue(store.held(id)); + assertThrows(TableOwnershipStore.Conflict.class, () -> store.save(id, row())); + } + + @Test + void ordinarySaveRestartRetainsNullsUnknownLedgerMetadataAndInertWagerEntries() + throws Exception { + JsonObject raw = row(), + stake = + RawJson.parse( + "{\"owner\":\"" + + account + + "\",\"item\":\"bytes\",\"typeKey\":\"coin\",\"unit\":1,\"count\":2,\"streetId\":1,\"unknown\":{\"nullable\":null}}"); + stake.addProperty("anchorX", 1); + raw.getAsJsonArray("ledger").add(stake); + JsonObject refs = new JsonObject(); + refs.addProperty(account.toString(), character.toString()); + refs.add("future", JsonNull.INSTANCE); + raw.add("wagerCharacters", refs); + Path path = write(raw); + var store = new TableOwnershipStore(data); + assertTrue(store.prepare(path)); + JsonObject known = row(); + var nextStake = stake.deepCopy(); + nextStake.remove("unknown"); + nextStake.addProperty("anchorX", 1.0); + nextStake.addProperty("count", 3); + known.getAsJsonArray("ledger").add(nextStake); + known.add("wagerCharacters", new JsonObject()); + store.save(id, known); + var saved = RawJson.read(path); + assertTrue(saved.getAsJsonObject("unknown").has("nullable")); + assertTrue( + saved.getAsJsonArray("ledger") + .get(0) + .getAsJsonObject() + .getAsJsonObject("unknown") + .has("nullable")); + assertEquals( + 3, saved.getAsJsonArray("ledger").get(0).getAsJsonObject().get("count").getAsInt()); + assertFalse(saved.getAsJsonObject("wagerCharacters").has(account.toString())); + assertTrue(saved.getAsJsonObject("wagerCharacters").get("future").isJsonNull()); + var restart = new TableOwnershipStore(data); + assertTrue(restart.prepare(path)); + restart.save(id, known); + assertEquals(saved, RawJson.read(path)); + assertEquals(1, restart.inventory().size()); + } + + @Test + void externalContentAndPresenceConflictsStayHeldWithoutRecreatingLostFiles() throws Exception { + Path path = write(row()); + var store = new TableOwnershipStore(data); + assertTrue(store.prepare(path)); + String original = Files.readString(path); + Files.writeString(path, original + " "); + assertThrows(IOException.class, () -> store.save(id, row())); + assertTrue(store.held(id)); + Files.writeString(path, original); + assertThrows(IOException.class, () -> store.save(id, row())); + assertEquals(original, Files.readString(path)); + var restart = new TableOwnershipStore(data); + assertTrue(restart.prepare(path)); + Files.delete(path); + assertThrows(IOException.class, () -> restart.save(id, row())); + assertFalse(Files.exists(path)); + } + + @Test + void rawInventoryIncludesUnloadableRowsAndRejectsDuplicateTableIds() throws Exception { + Path path = write(row()); + var store = new TableOwnershipStore(data); + assertEquals("missing", store.inventory().getFirst().raw().get("world").getAsString()); + Files.copy(path, path.resolveSibling(id + ".json")); + assertThrows(IOException.class, store::inventory); + assertTrue(store.prepare(path)); + assertThrows(IOException.class, () -> store.prepare(path.resolveSibling(id + ".json"))); + assertTrue(store.held(id)); + } +} diff --git a/src/test/java/net/tfminecraft/games/support/TestCharacters.java b/src/test/java/net/tfminecraft/games/support/TestCharacters.java index 80db940..bd45bce 100644 --- a/src/test/java/net/tfminecraft/games/support/TestCharacters.java +++ b/src/test/java/net/tfminecraft/games/support/TestCharacters.java @@ -19,9 +19,21 @@ public static CharacterRef ref(Player player) { } public static MockedStatic mockSessions() { MockedStatic scope = mockStatic(Characters.class); + java.util.Map known = new java.util.HashMap<>(); scope.when(() -> Characters.currentSession(any())).thenAnswer(call -> { Player player = call.getArgument(0); - return player == null ? Optional.empty() : Optional.of(new CharacterSession(ref(player), 1L)); + if(player == null) return Optional.empty(); + CharacterRef ref=ref(player);known.put(ref.characterId(),ref); + return Optional.of(new CharacterSession(ref, 1L)); + }); + scope.when(() -> Characters.info(any())).thenAnswer(call -> { + CharacterRef ref=known.get(call.getArgument(0)); + return ref==null?Optional.empty():Optional.of(new net.tfminecraft.tlibs.character.CharacterInfo(ref,"Fixture", + net.tfminecraft.tlibs.character.CharacterStatus.ALIVE,null,java.util.Set.of(),0,null)); + }); + scope.when(Characters::legacyHeirsReady).thenReturn(true); + scope.when(() -> Characters.legacyHeir(any())).thenAnswer(call -> { + UUID owner=call.getArgument(0);return Optional.of(new CharacterRef(owner,UUID.nameUUIDFromBytes(("character:"+owner).getBytes(StandardCharsets.UTF_8)))); }); scope.when(() -> Characters.displayName(any())).thenAnswer(call -> ((Player) call.getArgument(0)).getName()); scope.when(() -> Characters.isCurrent(any())).thenReturn(true); diff --git a/src/test/java/net/tfminecraft/games/table/OutgoingRefundTest.java b/src/test/java/net/tfminecraft/games/table/OutgoingRefundTest.java new file mode 100644 index 0000000..b33043a --- /dev/null +++ b/src/test/java/net/tfminecraft/games/table/OutgoingRefundTest.java @@ -0,0 +1,236 @@ +package net.tfminecraft.games.table; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import net.tfminecraft.games.Games; +import net.tfminecraft.games.ownership.CharacterAccess; +import net.tfminecraft.games.support.TestCharacters; +import net.tfminecraft.games.wager.*; +import net.tfminecraft.tlibs.character.*; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Item; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockbukkit.mockbukkit.MockBukkit; + +class OutgoingRefundTest extends TableManagerFixture { + private CharacterSession ending; + + private Table held(int value) { + Table table = place(false); + ending = new CharacterSession(TestCharacters.ref(player), 1); + table.wagerCharacters().put(player.getUniqueId(), ending.ref()); + Accounts.bucket(table, player.getUniqueId()).accept(List.of(new Stake(new ItemStack(Material.GOLD_NUGGET), "gold", 1, value, 1))); + table.actives().add(player.getUniqueId()); + games.when(() -> net.tfminecraft.games.game.GamesRegistry.of(table.getGameId())).thenReturn(null); + CharacterAccess.start(MockBukkit.createMockPlugin()); + player.openInventory(Bukkit.createInventory(null, 9)); + return table; + } + + private void clearSession() { + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.empty()); + characters.when(() -> Characters.isCurrent(ending)).thenReturn(false); + } + + private void end() { + Bukkit.getPluginManager().callEvent(new CharacterSessionEndEvent(player, ending, + CharacterSessionEndEvent.Reason.SWITCH)); + } + + private int coins() { return player.getInventory().all(Material.GOLD_NUGGET).values() + .stream().mapToInt(ItemStack::getAmount).sum(); } + + private void noDrops() { assertTrue(world.getEntitiesByClass(Item.class).isEmpty()); } + + private void heldUnchanged(Table table, int felt, int pockets) throws Exception { + assertEquals(felt, table.ledger().total()); + assertEquals(pockets, coins()); + assertTrue(table.moneyBlocked()); + var review = net.tfminecraft.games.ownership.RawJson.read(data.resolve("Data/wager-intents/" + table.getId() + ".json")); + assertTrue(review.toString().contains(table.wagerCharacters().get(player.getUniqueId()).characterId().toString())); + noDrops(); + } + + @Test void idlePrivateDealerFloatReturnsToOutgoingBody() { + Table table = held(0); + table.actives().clear(); + table.setDealerId(player.getUniqueId()); + table.setFloatOwner(player.getUniqueId()); + Accounts.tray(table).accept(List.of(new Stake(new ItemStack(Material.GOLD_NUGGET), "gold", 1, 10, 1))); + clearSession(); + end(); + assertEquals(10, coins()); + assertEquals(0, table.ledger().total()); + assertNull(table.floatOwner()); + assertFalse(table.moneyBlocked()); + assertFalse(WagerJournal.blocked(table)); + noDrops(); + } + + @Test void differentCapturedCharacterCannotReceiveEndingCharacterRefund() throws Exception { + Table table = held(7); + table.wagerCharacters().put(player.getUniqueId(), new CharacterRef(player.getUniqueId(), UUID.randomUUID())); + clearSession(); + end(); + heldUnchanged(table, 7, 0); + } + + @Test void successorDuringEndGetsNoOldCoins() throws Exception { + Table table = held(7); + clearSession(); + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.of( + new CharacterSession(new CharacterRef(player.getUniqueId(), UUID.randomUUID()), 2))); + end(); + heldUnchanged(table, 7, 0); + } + + @Test void deadBodyCannotReceiveNewDeathDrops() throws Exception { + Table table = held(7); + clearSession(); + player.setHealth(0); + assertTrue(player.isDead()); + player.openInventory(Bukkit.createInventory(null, 9)); + end(); + heldUnchanged(table, 7, 0); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void fullStorageHoldsUnlessMatchingPartialStacksCanFitEverything(boolean matching) throws Exception { + Table table = held(7); + ItemStack[] storage = player.getInventory().getStorageContents(); + java.util.Arrays.setAll(storage, slot -> new ItemStack(Material.DIAMOND, 64)); + if (matching) storage[0] = new ItemStack(Material.GOLD_NUGGET, 57); + player.getInventory().setStorageContents(storage); + clearSession(); + end(); + if (matching) { + assertEquals(64, coins()); + assertEquals(0, table.ledger().total()); + assertFalse(table.moneyBlocked()); + assertFalse(WagerJournal.blocked(table)); + noDrops(); + } else heldUnchanged(table, 7, 0); + } + + @Test void multipleRecipientInstancesShareOneCapacityReservation() throws Exception { + Table table = held(5); + Accounts.tray(table).accept(List.of(new Stake(new ItemStack(Material.GOLD_NUGGET), "gold", 1, 3, 1))); + ItemStack[] storage = player.getInventory().getStorageContents(); + java.util.Arrays.setAll(storage, slot -> new ItemStack(Material.DIAMOND, 64)); + storage[0] = new ItemStack(Material.GOLD_NUGGET, 58); + player.getInventory().setStorageContents(storage); + clearSession(); + OutgoingRefund.during(player, ending, () -> { + var result = WagerEngine.get().begin(table, "compound refund") + .moveAll(Accounts.bucket(table, player.getUniqueId()), Accounts.payee(table, player, player.getUniqueId())) + .moveAll(Accounts.tray(table), Accounts.payee(table, player, player.getUniqueId())).commit(); + assertFalse(result.ok()); + }); + heldUnchanged(table, 8, 58); + } + + @Test void endingPocketsCannotFundAnyNewSourceLeg() { + Table table = held(0); + player.getInventory().setItem(0, new ItemStack(Material.GOLD_NUGGET, 7)); + clearSession(); + OutgoingRefund.during(player, ending, () -> { + var result = WagerEngine.get().begin(table, "new stake") + .move(Accounts.coins(table, player), Accounts.bucket(table, player.getUniqueId()), 7).commit(); + assertFalse(result.ok()); + assertEquals(TxResult.Reason.PLAYER_SHORT, result.reason()); + }); + assertEquals(7, coins()); + assertEquals(0, table.ledger().total()); + noDrops(); + } + + @Test void outgoingScopeDoesNotAuthorizeAnotherAccount() { + Table table = held(7); + var other = opponent(); + table.wagerCharacters().put(other.getUniqueId(), TestCharacters.ref(other)); + clearSession(); + characters.when(() -> Characters.currentSession(other)).thenReturn(Optional.empty()); + OutgoingRefund.during(player, ending, () -> assertFalse(WagerEngine.get().begin(table, "other refund") + .moveAll(Accounts.bucket(table, player.getUniqueId()), Accounts.payee(table, other, other.getUniqueId())).commit().ok())); + assertEquals(7, table.ledger().total()); + assertEquals(0, coins()); + assertFalse(other.getInventory().contains(Material.GOLD_NUGGET)); + assertTrue(table.moneyBlocked()); + noDrops(); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void deferredPayoutHasNoEndingAuthorityAfterScopeCloses(boolean successor) { + Table table = held(7); + clearSession(); + AtomicReference deferred = new AtomicReference<>(); + assertThrows(IllegalStateException.class, () -> OutgoingRefund.during(player, ending, () -> { + deferred.set(WagerEngine.get().begin(table, "deferred refund") + .moveAll(Accounts.bucket(table, player.getUniqueId()), Accounts.payee(table, player, player.getUniqueId()))); + throw new IllegalStateException("cleanup cut"); + })); + if (successor) characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.of( + new CharacterSession(new CharacterRef(player.getUniqueId(), UUID.randomUUID()), 2))); + assertFalse(deferred.get().commit().ok()); + assertEquals(7, table.ledger().total()); + assertEquals(0, coins()); + assertTrue(table.moneyBlocked()); + noDrops(); + } + + @Test void capturedTaxedPotPreservesFullJournalEnvelopeAndConservation() throws Exception { + Table table = held(10); + clearSession(); + AtomicReference> posting = new AtomicReference<>(); + denars.when(() -> net.tfminecraft.denareconomy.api.Denars.post(anyString(), anyString(), anyList())).thenAnswer(call -> { + var intent = net.tfminecraft.games.ownership.RawJson.read(data.resolve("Data/wager-intents/" + table.getId() + ".json")); + var ref = intent.getAsJsonObject("plan").getAsJsonObject("characters").getAsJsonObject(player.getUniqueId().toString()); + assertEquals(ending.ref().ownerId().toString(), ref.get("ownerId").getAsString()); + assertEquals(ending.ref().characterId().toString(), ref.get("characterId").getAsString()); + var tax = intent.getAsJsonObject("plan").getAsJsonObject("taxQuote"); + assertEquals("guildtax:captured", tax.get("account").getAsString()); + assertEquals(ref, tax.getAsJsonObject("earner")); + posting.set(List.copyOf(call.getArgument(2))); + return net.tfminecraft.denareconomy.api.Result.APPLIED; + }); + try (var tax = mockStatic(CitizenTax.class)) { + tax.when(() -> CitizenTax.quote(ending.ref(), 10)).thenReturn(new CitizenTax.Levy(2, 2.0, ending.ref(), "guildtax:captured")); + OutgoingRefund.during(player, ending, () -> assertEquals(10, WagerEngine.get() + .payFromPot(table, player, player.getUniqueId(), 10, null, "outgoing win").moved())); + } + assertEquals(8, coins()); + assertEquals(0, table.ledger().total()); + assertEquals(0, posting.get().stream().mapToLong(net.tfminecraft.denareconomy.api.Leg::cents).sum()); + assertEquals(200, posting.get().stream().filter(leg -> leg.account().equals("guildtax:captured")) + .mapToLong(net.tfminecraft.denareconomy.api.Leg::cents).sum()); + assertFalse(table.moneyBlocked()); + assertFalse(WagerJournal.blocked(table)); + noDrops(); + } + + @Test @SuppressWarnings("removal") + void quitBeforeProviderEndUsesOrdinaryAuthorityAndEndDoesNotPayTwice() { + Table table = held(7); + manager.onQuit(new PlayerQuitEvent(player, "quit")); + assertEquals(7, coins()); + assertEquals(0, table.ledger().total()); + clearSession(); + end(); + assertEquals(7, coins()); + assertEquals(0, table.ledger().total()); + assertFalse(table.moneyBlocked()); + assertFalse(WagerJournal.blocked(table)); + noDrops(); + } +} diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerActionTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerActionTest.java index 319509c..dfe5215 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerActionTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerActionTest.java @@ -63,6 +63,7 @@ void selectingGameWithoutSurfaceArmsTheNextPlacement(boolean requireDeck) { @Test void blackjackOwnerAndStaffCanOpenOptionsButLiveGameCannotBeEdited() { Table table = blackjack(); + table.setStaffMint(false); // This test exercises private owner editing, separate from staff mint. PlayerMock staff = opponent(); staff.addAttachment(Games.plugin, "games.admin", true); player.setSneaking(true); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerFixture.java b/src/test/java/net/tfminecraft/games/table/TableManagerFixture.java index b6f5d84..65c7b3e 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerFixture.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerFixture.java @@ -69,6 +69,7 @@ abstract class TableManagerFixture { MockedStatic anchors; MockedStatic games; MockedStatic messages; + MockedStatic characters; List catalog; private int oldDealTicks, oldRecycleTicks, oldPayoutTicks, oldVoteSeconds, oldMinPlayers; private boolean oldShowChips; @@ -124,7 +125,7 @@ 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()); + characters = 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); @@ -214,7 +215,9 @@ private void restoreManager(List snapshot) { private record SingletonField(Field field, Object original, Object contents) { @SuppressWarnings({"rawtypes", "unchecked"}) void restore(TableManager manager) throws IllegalAccessException { - if (original instanceof Map map) { + if (!Modifier.isFinal(field.getModifiers())) { + field.set(manager, original); + } else if (original instanceof Map map) { map.clear(); map.putAll((Map) contents); } else if (original instanceof Collection collection) { diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerHouseTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerHouseTest.java index f401f7a..47f2879 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerHouseTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerHouseTest.java @@ -30,8 +30,8 @@ class TableManagerHouseTest extends TableManagerFixture { @Test void aTableWithNoRecordedOwnerCanOnlyBeEditedByStaff() { Table table = placeAt("freeplay", 0); TableHouse house = TableHouse.from(table); - house.setOwnerPlayer(null); - manager.applyHouse(table, house); + table.setOwnerPlayer(null); + manager.persistHouseChange(table); assertNull(table.ownerPlayer()); assertFalse(manager.canEditHouse(player, table)); player.addAttachment(Games.plugin, "games.admin", true); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerLifecycleTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerLifecycleTest.java index 79641ba..ba601b0 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerLifecycleTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerLifecycleTest.java @@ -88,7 +88,7 @@ class TableManagerLifecycleTest extends TableManagerFixture { } } - @Test void aNullSeatListLoadsAsAnIdleTableWithoutRewritingTheFile() throws Exception { + @Test void aNullSeatListLoadsIdlePreservingRawFieldsWhileLegacyModeFreezes() throws Exception { UUID id = UUID.randomUUID(); JsonObject saved = document(id, "freeplay"); saved.add("actives", JsonNull.INSTANCE); @@ -102,8 +102,9 @@ class TableManagerLifecycleTest extends TableManagerFixture { assertNotNull(loaded); assertTrue(loaded.actives().isEmpty()); assertEquals(List.of("one", "two"), loaded.getDeck().remainingIds()); - assertEquals(before, Files.readString(folder().resolve(id + ".json")), - "Only a table with a session to undo is written back on load"); + JsonObject expected=saved.deepCopy();expected.addProperty("staffMint",false); + assertEquals(expected,net.tfminecraft.games.ownership.RawJson.read(folder().resolve(id+".json")),"Mode freezing must retain the null seats and captured wager identity"); + try(var files=Files.walk(data.resolve("migrations/table-ownership"))) { assertEquals(before,Files.readString(files.filter(path->path.toString().endsWith(".original")).findFirst().orElseThrow())); } } @Test void aStaleTableOfARetiredGameIsRefundedAndLabelledWithoutGameHooks() throws Exception { @@ -308,10 +309,11 @@ private void expireArm() throws Exception { gameId.setAccessible(true); requireDeck.setAccessible(true); house.setAccessible(true); - Constructor make = type.getDeclaredConstructor(String.class, long.class, boolean.class, TableHouse.class); + Field token = type.getDeclaredField("token");token.setAccessible(true); + Constructor make = type.getDeclaredConstructor(String.class, long.class, boolean.class, TableHouse.class, net.tfminecraft.games.ownership.OwnerSession.class); make.setAccessible(true); arms.put(player.getUniqueId(), make.newInstance(gameId.get(live), System.currentTimeMillis() - 1L, - requireDeck.get(live), house.get(live))); + requireDeck.get(live), house.get(live),token.get(live))); } private ChunkLoadEvent chunkLoad(World where, int x, int z) { diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java index e3d8c75..17c1e82 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java @@ -81,25 +81,28 @@ void legacyHouseFieldsUseConfiguredDefaultsWithoutInventingAnOwner(boolean autom } } + @ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({"true,missing,missing,true", "true,null,false,false", "false,null,true,true", "true,true,missing,false", "false,false,true,true", "true,null,null,true"}) + void storedAndLegacyEffectiveModeMatchRawClassificationWithoutOwnerRecapture(boolean yamlAuto,String auto,String mint,boolean expectedMint) throws Exception { + TableLayout previous=Cache.tableLayouts.put("freeplay",new TableLayout(Cache.pokerCardSet,"Cards","icon",6,Map.of(),null,null,null,0,false,yamlAuto,5,80,10)); + try { + UUID id=UUID.randomUUID(),recorded=UUID.randomUUID();JsonObject saved=document(id);saved.addProperty("ownerPlayer",player.getUniqueId().toString());saved.addProperty("ownerCharacterId",recorded.toString());saved.addProperty("ownerIdentityVersion",2);saved.addProperty("houseFloat",0); + if(auto.equals("missing"))saved.remove("autoDealer");else if(auto.equals("null"))saved.add("autoDealer",com.google.gson.JsonNull.INSTANCE);else saved.addProperty("autoDealer",Boolean.parseBoolean(auto)); + if(mint.equals("missing"))saved.remove("staffMint");else if(mint.equals("null"))saved.add("staffMint",com.google.gson.JsonNull.INSTANCE);else saved.addProperty("staffMint",Boolean.parseBoolean(mint)); + write(id+".json",saved);manager.loadAll();Table loaded=manager.table(id);assertNotNull(loaded);assertEquals(expectedMint,loaded.staffMint()); + assertEquals(expectedMint?net.tfminecraft.games.ownership.OwnershipKind.STAFF:net.tfminecraft.games.ownership.OwnershipKind.PRIVATE,manager.ownershipStore().row(folder().resolve(id+".json")).kind()); + assertEquals(recorded,loaded.ownerCharacterId());assertEquals(player.getUniqueId(),loaded.ownerPlayer());assertEquals(0,loaded.houseFloat()); + var raw=net.tfminecraft.games.ownership.RawJson.read(folder().resolve(id+".json"));assertEquals(expectedMint,raw.get("staffMint").getAsBoolean());assertEquals(recorded,UUID.fromString(raw.get("ownerCharacterId").getAsString()));assertEquals(0,raw.get("houseFloat").getAsInt()); + } finally { if(previous==null)Cache.tableLayouts.remove("freeplay");else Cache.tableLayouts.put("freeplay",previous); } + } + @Test - void malformedHouseOwnerDoesNotDiscardValidTableCardsOrRefundableStakes() throws Exception { - UUID id = UUID.randomUUID(); - JsonObject saved = document(id); - saved.addProperty("ownerPlayer", "old-owner-is-not-a-uuid"); - saved.getAsJsonArray("ledger").add(stake(player.getUniqueId(), 3)); - write(id + ".json", saved); - manager.loadAll(); - Table loaded = manager.table(id); - assertNotNull(loaded); - assertNull(loaded.ownerPlayer()); - assertFalse(loaded.autoDealer()); - assertFalse(loaded.staffMint()); - assertEquals(1, loaded.minBet()); - assertEquals(20, loaded.maxBet()); - assertEquals(2, loaded.getDeck().remaining() + loaded.getDeck().discarded()); - assertEquals(3, inventoryGold()); - assertEquals(0, droppedGold()); - assertTrue(loaded.ledger().isEmpty()); + void malformedHouseOwnerHoldsRawCardsAndStakesWithoutNormalizingOrRefundingThem() throws Exception { + UUID id = UUID.randomUUID();JsonObject saved = document(id); + saved.addProperty("ownerPlayer", "old-owner-is-not-a-uuid");saved.getAsJsonArray("ledger").add(stake(player.getUniqueId(), 3)); + write(id + ".json", saved);String before=Files.readString(folder().resolve(id+".json"));manager.loadAll(); + assertNull(manager.table(id));assertEquals(before,Files.readString(folder().resolve(id+".json"))); + assertEquals(0,inventoryGold());assertEquals(0,droppedGold()); } @Test @@ -410,30 +413,18 @@ void legacyPileWithUnknownOwnerDropsRecoverableCoinsAndReportsUnreadableItems() } @Test - void failedSaveKeepsCommittedStakeAndCanPersistItAfterFilesystemRecovery() throws Exception { - Table table = place(false); - Path path = folder().resolve(table.getId() + ".json"); - Files.delete(path); - Files.createDirectory(path); - Path obstruction = path.resolve("occupied"); - Files.writeString(obstruction, "unrelated contents"); - stakeCoin(player, table); - assertEquals(1, table.ledger().total()); - assertEquals(0, inventoryGold()); - assertEquals("unrelated contents", Files.readString(obstruction)); - verify(Games.plugin.getLogger(), atLeastOnce()).warning(contains("Failed to save table " + table.getId())); - Files.delete(obstruction); - Files.delete(path); - manager.applyHouse(table, TableHouse.from(table)); - JsonArray saved = read(table.getId()).getAsJsonArray("ledger"); - assertEquals(1, saved.size()); - assertEquals(1, saved.get(0).getAsJsonObject().get("count").getAsInt()); - assertEquals(player.getUniqueId().toString(), saved.get(0).getAsJsonObject().get("owner").getAsString()); - assertEquals(1, table.ledger().total() + inventoryGold()); + void replacedSourceHoldsNewStakesAndRefusesAutomaticRecreationAfterFilesystemRecovery() throws Exception { + Table table = place(false);Path path = folder().resolve(table.getId() + ".json"); + Files.delete(path);Files.createDirectory(path);Path obstruction = path.resolve("occupied");Files.writeString(obstruction,"unrelated contents"); + player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET,1)); + clickFelt(player,table);assertEquals(0,table.ledger().total());assertEquals(1,inventoryGold()); + assertEquals("unrelated contents",Files.readString(obstruction));assertTrue(table.ownershipHeld()); + Files.delete(obstruction);Files.delete(path);manager.applyHouse(table,TableHouse.from(table)); + assertFalse(Files.exists(path));assertEquals(1,inventoryGold());assertTrue(table.ownershipHeld()); } @Test - void failedFileDeletionReportsObstructionWhilePickupReturnsPlayersMoney() throws Exception { + void obstructedSourceHoldsPickupAndPreservesCommittedMoney() throws Exception { Table table = place(false); stakeCoin(player, table); Path path = folder().resolve(table.getId() + ".json"); @@ -448,12 +439,12 @@ void failedFileDeletionReportsObstructionWhilePickupReturnsPlayersMoney() throws when(pickup.getDamager()).thenReturn(player); manager.onHitEntity(pickup); verify(pickup).setCancelled(true); - assertNull(manager.table(table.getId())); - assertTrue(table.ledger().isEmpty()); - assertEquals(1, inventoryGold()); + assertSame(table,manager.table(table.getId())); + assertEquals(1,table.ledger().total()); + assertEquals(0, inventoryGold()); assertEquals(0, droppedGold()); assertEquals("unrelated contents", Files.readString(obstruction)); - verify(Games.plugin.getLogger()).warning("[Games] Could not delete table file " + table.getId() + ".json"); + assertTrue(table.ownershipHeld()); } @ParameterizedTest @@ -584,7 +575,7 @@ void anAbsentDealersTraySurvivesARestartAndGoesBackWhenTheyLogIn() throws Except .mapToInt(ItemStack::getAmount).sum()); assertEquals(0, manager.trayDenars(loaded)); assertNull(loaded.floatOwner()); - assertFalse(read(id).has("floatOwner")); + assertTrue(read(id).get("floatOwner").isJsonNull()); manager.onJoin(new PlayerJoinEvent(player, "join")); assertEquals(0, inventoryGold(), "logging in pays nobody else"); } diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerPlayChatTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerPlayChatTest.java index 94d7da3..f9fbfa6 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerPlayChatTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerPlayChatTest.java @@ -11,6 +11,62 @@ @SuppressWarnings("deprecation") class TableManagerPlayChatTest extends TableManagerFixture { + @Test void asyncChatSeesCompletePreviousSnapshotDuringBothPublicationPaths() throws Exception { + Table table = place(false); + manager.beginSession(table); + table.setActor(player.getUniqueId()); + when(game.allowPlayChat(table, player)).thenReturn(true); + manager.publishChatActors(); + var field = TableManager.class.getDeclaredField("chatActors"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + var before = (java.util.Map) field.get(manager); + assertThrows(UnsupportedOperationException.class, before::clear); + var reader = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + when(game.allowPlayChat(table, player)).thenAnswer(call -> { + var event = reader.submit(() -> chat("hit")).get(5, java.util.concurrent.TimeUnit.SECONDS); + assertTrue(event.isCancelled(), "the async reader must retain the complete published actor"); + return true; + }); + manager.publishChatActors(); + manager.chatActorChanged(table); + } finally { + reader.shutdownNow(); + } + verify(game, never()).onBetHit(any(), any()); + table.setActor(null); + assertEquals(java.util.Map.of(player.getUniqueId(), table.getId()), before, + "old readers retain an immutable snapshot after a later publication"); + tick(1); + verify(game, never()).onBetHit(any(), any()); + } + + @Test void queuedChatCannotMoveToAnotherTableOrRenewedCharacterSession() { + Table table = place(false); + manager.beginSession(table); + when(game.allowPlayChat(table, player)).thenReturn(true); + table.setActor(player.getUniqueId()); + assertTrue(chat("hit").isCancelled()); + manager.armPlace(player, "freeplay", false); + assertTrue(manager.tryPlace(player, table.getOrigin().clone().add(20, 0, 0))); + Table other = manager.tables().stream().filter(candidate -> candidate != table).findFirst().orElseThrow(); + player.teleport(other.getOrigin()); + manager.beginSession(other); + when(game.allowPlayChat(other, player)).thenReturn(true); + other.setActor(player.getUniqueId()); + tick(1); + verify(game, never()).onBetHit(any(), any()); + assertTrue(chat("hit").isCancelled()); + try (var access = mockStatic(net.tfminecraft.games.ownership.CharacterAccess.class, CALLS_REAL_METHODS)) { + access.when(() -> net.tfminecraft.games.ownership.CharacterAccess.capture(player)) + .thenReturn(new net.tfminecraft.games.ownership.OwnerSession(player.getUniqueId(), + table.ownerCharacterId(), 2)); + tick(1); + } + verify(game, never()).onBetHit(any(), any()); + } + @Test void playWordsAreOrdinaryChatUnlessTheGameAcceptsThemFromTheActingSeat() { Table table = place(false); player.teleport(table.getOrigin().clone().add(100, 0, 0)); @@ -23,6 +79,7 @@ class TableManagerPlayChatTest extends TableManagerFixture { when(game.allowPlayChat(table, player)).thenReturn(false); assertFalse(chat("hit").isCancelled(), "the game is not taking play words right now"); when(game.allowPlayChat(table, player)).thenReturn(true); + manager.publishChatActors(); // The main-thread phase boundary publishes immutable chat eligibility. assertTrue(chat("Hit!").isCancelled()); verify(game, never()).onBetHit(any(), any()); tick(1); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerStoredDataTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerStoredDataTest.java index 2115ee2..dfe39ad 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerStoredDataTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerStoredDataTest.java @@ -186,7 +186,7 @@ private int droppedGold() { assertEquals(3, table.ledger().total(), "the money itself is still on the felt"); } - @Test void pickingUpATableWhoseFileWasAlreadyRemovedIsQuiet() throws Exception { + @Test void missingSourceHoldsPickupWithoutRecreatingOrDestroyingTheRuntimeTable() throws Exception { Table table = place(false); Files.delete(folder().resolve(table.getId() + ".json")); Entity anchor = mock(Entity.class); @@ -195,7 +195,7 @@ private int droppedGold() { when(hit.getEntity()).thenReturn(anchor); when(hit.getDamager()).thenReturn(player); manager.onHitEntity(hit); - assertNull(manager.table(table.getId())); + assertSame(table,manager.table(table.getId()));assertTrue(table.ownershipHeld()); assertFalse(Files.exists(folder().resolve(table.getId() + ".json"))); verify(Games.plugin.getLogger(), never()).warning(contains("Could not delete")); } diff --git a/src/test/java/net/tfminecraft/games/table/TableOwnerSessionTest.java b/src/test/java/net/tfminecraft/games/table/TableOwnerSessionTest.java new file mode 100644 index 0000000..80e886a --- /dev/null +++ b/src/test/java/net/tfminecraft/games/table/TableOwnerSessionTest.java @@ -0,0 +1,206 @@ +package net.tfminecraft.games.table; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import net.tfminecraft.games.Games; +import net.tfminecraft.games.guild.GuildTables; +import net.tfminecraft.games.ownership.*; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; + +class TableOwnerSessionTest extends TableManagerFixture { + @Test + void realEndEventAfterSessionClearsRefundsOnlyCapturedOutgoingInventory() { + Table table = place(false); + var ended = new net.tfminecraft.tlibs.character.CharacterSession( + net.tfminecraft.games.support.TestCharacters.ref(player), 1); + player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 7)); + assertEquals(7, net.tfminecraft.games.wager.WagerEngine.get().begin(table, "stake") + .move(net.tfminecraft.games.wager.Accounts.coins(table, player), + net.tfminecraft.games.wager.Accounts.bucket(table, player.getUniqueId()), 7).commit().moved()); + table.actives().add(player.getUniqueId()); + games.when(() -> net.tfminecraft.games.game.GamesRegistry.of(table.getGameId())).thenReturn(null); + CharacterAccess.start(org.mockbukkit.mockbukkit.MockBukkit.createMockPlugin()); + player.openInventory(org.bukkit.Bukkit.createInventory(null, 9)); + characters.when(() -> net.tfminecraft.tlibs.character.Characters.currentSession(player)) + .thenReturn(Optional.empty()); + characters.when(() -> net.tfminecraft.tlibs.character.Characters.isCurrent(ended)).thenReturn(false); + org.bukkit.Bukkit.getPluginManager().callEvent(new net.tfminecraft.tlibs.character.CharacterSessionEndEvent( + player, ended, net.tfminecraft.tlibs.character.CharacterSessionEndEvent.Reason.SWITCH)); + assertEquals(7, net.tfminecraft.games.wager.Accounts.coins(table, player).available()); + assertEquals(0, table.ledger().total()); + assertFalse(table.moneyBlocked()); + assertFalse(net.tfminecraft.games.wager.WagerJournal.blocked(table)); + assertFalse(table.actives().contains(player.getUniqueId())); + assertNull(CharacterAccess.published(player.getUniqueId())); + var next = new net.tfminecraft.tlibs.character.CharacterSession( + new net.tfminecraft.tlibs.character.CharacterRef(player.getUniqueId(), UUID.randomUUID()), 2); + // Model the provider saving A's seven refunded coins before loading B's empty inventory. + player.getInventory().clear(); + characters.when(() -> net.tfminecraft.tlibs.character.Characters.currentSession(player)) + .thenReturn(Optional.of(next)); + assertEquals(0, net.tfminecraft.games.wager.Accounts.coins(table, player).available()); + assertEquals(0, table.ledger().total()); + } + @Test + void hotOwnershipChecksDoNotReadSourcesButDeferredPassDetectsSameSizeSameTimeEdits() throws Exception { + Table table = place(false); + var file = data.resolve("Data/tables/" + table.getId() + ".json"); + var store = manager.ownershipStore(); + try (var raw = mockStatic(java.nio.file.Files.class, CALLS_REAL_METHODS)) { + for (int i = 0; i < 100; i++) assertFalse(table.ownershipHeld()); + raw.verify(() -> java.nio.file.Files.readAllBytes(file), never()); + } + String original = java.nio.file.Files.readString(file); + var time = java.nio.file.Files.getLastModifiedTime(file); + String changed = original.replace(table.ownerCharacterId().toString(), UUID.randomUUID().toString()); + assertNotEquals(original, changed); + java.nio.file.Files.writeString(file, changed); + java.nio.file.Files.setLastModifiedTime(file, time); + manager.loadDeferred(); + assertTrue(table.ownershipHeld()); + assertTrue(store.held(table.getId())); + assertEquals(changed, java.nio.file.Files.readString(file)); + } + + @Test + void deferredPassHoldsDeletedSourcesEvenWhenFolderInventoryIsEmpty() throws Exception { + Table table = place(false); + var file = data.resolve("Data/tables/" + table.getId() + ".json"); + java.nio.file.Files.delete(file); + manager.loadDeferred(); + assertTrue(table.ownershipHeld()); + assertFalse(java.nio.file.Files.exists(file)); + assertThrows(java.io.IOException.class, () -> manager.checkpoint(table)); + assertFalse(java.nio.file.Files.exists(file)); + } + @Test + void duplicateRawIdsAreHeldBeforeAnyOwnerOrGuildMigrationPublishes() throws Exception { + UUID id = UUID.randomUUID(); + java.nio.file.Path folder = data.resolve("Data/tables"); + java.nio.file.Files.createDirectories(folder); + String one = + "{\"id\":\"" + + id + + "\",\"ownerPlayer\":\"" + + player.getUniqueId() + + "\",\"ownerGuildId\":\"guild\",\"unknown\":null}"; + String two = + "{\"id\":\"" + + id + + "\",\"ownerPlayer\":\"" + + UUID.randomUUID() + + "\",\"unknown\":7}"; + java.nio.file.Files.writeString(folder.resolve("one.json"), one); + java.nio.file.Files.writeString(folder.resolve("two.json"), two); + manager.loadAll(); + assertEquals(one, java.nio.file.Files.readString(folder.resolve("one.json"))); + assertEquals(two, java.nio.file.Files.readString(folder.resolve("two.json"))); + assertFalse(java.nio.file.Files.exists(data.resolve("migrations"))); + assertTrue(manager.tables().isEmpty()); + assertTrue(manager.ownershipStore().held(id)); + } + + @Test + void missedEndDoesNotLetSameAccountBUseAsPlacementOrPrivateOwner() { + var a = new OwnerSession(player.getUniqueId(), UUID.randomUUID(), 1); + var b = new OwnerSession(player.getUniqueId(), UUID.randomUUID(), 2); + var active = new AtomicReference<>(a); + try (var access = mockStatic(CharacterAccess.class, CALLS_REAL_METHODS)) { + access.when(() -> CharacterAccess.capture(player)).thenAnswer(call -> active.get()); + player.getInventory().setItemInMainHand(new ItemStack(Material.PAPER, 3)); + manager.armPlace(player, "freeplay", true); + active.set(b); + assertTrue(manager.tryPlace(player, player.getLocation())); + assertTrue(manager.tables().isEmpty()); + assertEquals(3, player.getInventory().getItemInMainHand().getAmount()); + active.set(a); + Table table = place(false); + assertTrue(manager.canEditHouse(player, table)); + assertTrue(GuildTables.mayDeal(table, player)); + assertTrue(table.hostedBy(player.getUniqueId())); + active.set(b); + assertFalse(manager.canEditHouse(player, table)); + assertFalse(GuildTables.mayDeal(table, player)); + assertFalse(table.hostedBy(player.getUniqueId())); + } + } + + @Test + void staffSnapshotCannotRestoreOwnerAcrossTransferOrSameRefCycle() { + Table table = place(false); + TableHouse stale = TableHouse.from(table); + stale.setAutoDealer(true); + long original = table.ownerGeneration(); + UUID account = table.ownerPlayer(), character = table.ownerCharacterId(); + manager.estateOwner(table.getId(), UUID.randomUUID(), UUID.randomUUID()); + manager.estateOwner(table.getId(), account, character); + assertTrue(table.ownerGeneration() > original); + assertFalse(stale.matchesOwner(table)); + manager.applyHouse(table, stale); + assertFalse(table.autoDealer()); + var staff = opponent(); + staff.addAttachment(Games.plugin, "games.admin", true); + assertTrue(manager.canEditHouse(staff, table)); + TableHouse fresh = TableHouse.from(table); + fresh.setAutoDealer(true); + manager.applyHouse(table, fresh); + assertTrue(table.autoDealer()); + } + + @Test + void placementTemplateAlwaysBelongsToItsNewCapturedPlacer() { + Table old = place(false); + TableHouse template = TableHouse.from(old); + var placer = opponent(); + manager.armPlace(placer, "freeplay", false, template); + assertTrue(manager.tryPlace(placer, old.getOrigin().clone().add(10, 0, 0))); + Table created = + manager.tables().stream().filter(table -> table != old).findFirst().orElseThrow(); + assertEquals(placer.getUniqueId(), created.ownerPlayer()); + assertEquals( + net.tfminecraft.games.support.TestCharacters.ref(placer).characterId(), + created.ownerCharacterId()); + } + + @Test + void pendingTableRejectsNewRoundAndDealingWithoutBlockingExistingSessionEnd() { + Table table = place(false); + manager.estatePending(List.of(table.getId()), true); + manager.beginSession(table); + assertFalse(table.live()); + assertFalse(GuildTables.mayDeal(table, player)); + assertFalse(manager.canEditHouse(player, table)); + table.startSession(); + manager.endSession(table); + assertFalse(table.live()); + assertTrue(table.estatePending()); + } + + @Test + void failedDurableSessionCheckpointCannotStartGameEffects() throws Exception { + Table table = place(false); + var file = data.resolve("Data/tables/" + table.getId() + ".json"); + String source = java.nio.file.Files.readString(file); + try (var writer = + mockStatic( + net.tfminecraft.games.persistence.DurableFiles.class, CALLS_REAL_METHODS)) { + writer.when( + () -> + net.tfminecraft.games.persistence.DurableFiles.write( + eq(file), anyString())) + .thenThrow(new java.io.IOException("session checkpoint cut")); + manager.beginSession(table); + assertTrue(table.ownershipHeld()); + assertTrue(table.moneyBlocked()); + verify(game, never()).onSessionStart(table); + assertEquals(source, java.nio.file.Files.readString(file)); + } + } +} diff --git a/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java b/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java index 2477d94..5d16efd 100644 --- a/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java +++ b/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java @@ -112,6 +112,7 @@ private PlayerAccount payee(UUID owner, net.tfminecraft.tlibs.character.Characte when(payee.flightTarget()).thenReturn(owner); when(payee.online()).thenReturn(true); when(payee.character()).thenReturn(character); + when(payee.payeeCharacter()).thenReturn(character); when(payee.canAccept(anyInt())).thenReturn(true); when(payee.accept(anyList())).thenAnswer(call -> TableLedger.valueOf(call.getArgument(0))); return payee; diff --git a/src/test/java/net/tfminecraft/games/wager/OutgoingRefundThreadTest.java b/src/test/java/net/tfminecraft/games/wager/OutgoingRefundThreadTest.java new file mode 100644 index 0000000..024cb90 --- /dev/null +++ b/src/test/java/net/tfminecraft/games/wager/OutgoingRefundThreadTest.java @@ -0,0 +1,36 @@ +package net.tfminecraft.games.wager; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import net.tfminecraft.games.support.TestCharacters; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; + +class OutgoingRefundThreadTest { + @Test void asyncLookupFailsClosedAndCannotOpenAScope() { + var player = MockBukkit.getMock().addPlayer(); + var ending = new CharacterSession(TestCharacters.ref(player), 1); + var reader = Executors.newSingleThreadExecutor(); + try (var characters = TestCharacters.mockSessions()) { + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.empty()); + characters.when(() -> Characters.isCurrent(ending)).thenReturn(false); + OutgoingRefund.during(player, ending, () -> { + assertEquals(ending.ref(), OutgoingRefund.recipient(player)); + var result = reader.submit(() -> { + assertNull(OutgoingRefund.recipient(player)); + var ran = new AtomicBoolean(); + assertThrows(IllegalStateException.class, + () -> OutgoingRefund.during(player, ending, () -> ran.set(true))); + assertFalse(ran.get()); + }); + assertDoesNotThrow(() -> result.get(5, TimeUnit.SECONDS)); + }); + assertNull(OutgoingRefund.recipient(player)); + } finally { reader.shutdownNow(); } + } +} diff --git a/tools/ownership-rollback.py b/tools/ownership-rollback.py new file mode 100644 index 0000000..d379e6a --- /dev/null +++ b/tools/ownership-rollback.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Preview one verified table ownership restore; apply only after a clean plugin stop.""" +import argparse +from decimal import Decimal +import hashlib +import json +import os +from pathlib import Path +import tempfile +import uuid + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def load(data): + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("Duplicate JSON field") + result[key] = value + return result + return json.loads(data, object_pairs_hook=unique, parse_int=Decimal, parse_float=Decimal) + + +def same(left, right): + if type(left) is not type(right): + return False + if isinstance(left, dict): + return left.keys() == right.keys() and all(same(value, right[key]) for key, value in left.items()) + if isinstance(left, list): + return len(left) == len(right) and all(same(a, b) for a, b in zip(left, right)) + return left == right + + +def identity(value, nullable=False): + if nullable and value is None: + return None + if not isinstance(value, str) or str(uuid.UUID(value)) != value.lower(): + raise ValueError("Invalid frozen UUID") + return str(uuid.UUID(value)) + + +def publish(path, data): + descriptor, temporary = tempfile.mkstemp(prefix=".rollback-", dir=path.parent) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + current = path.parent + while True: + descriptor = os.open(current, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + if current.parent == current: + break + current = current.parent + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("data", type=Path, help="Stopped Games plugin data directory") + parser.add_argument("table", help="Original filename beneath Data/tables") + parser.add_argument("before", help="64-character SHA256 naming the migration plan") + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + if Path(args.table).name != args.table or not args.table.endswith(".json"): + parser.error("Use a table filename, without a directory") + if len(args.before) != 64 or any(char not in "0123456789abcdef" for char in args.before): + parser.error("Use the migration plan's full original SHA256") + root = args.data.resolve() + directory = root / "migrations/table-ownership" / digest(args.table.encode()) + plan = load((directory / (args.before + ".json")).read_bytes()) + archive = (directory / (args.before + ".original")).read_bytes() + source = root / "Data/tables" / args.table + current = source.read_bytes() + if plan.get("complete") is not True or "character" not in plan or "account" not in plan or plan.get("source") != args.table or plan.get("before") != args.before or digest(archive) != args.before: + raise ValueError("Migration intent/archive requires review") + original = load(archive) + table_id = identity(original.get("id")) + account = identity(original.get("ownerPlayer"), nullable=True) + character = identity(plan.get("character"), nullable=True) + owner_change = plan.get("ownerChange", True) + if type(owner_change) is not bool: + raise ValueError("Invalid ownership migration marker") + expected = dict(original) + if owner_change: + if account is None or original.get("ownerCharacterId") is not None: + raise ValueError("Owner mapping lacks its account") + expected["ownerIdentityVersion"] = Decimal(2) + expected["ownerCharacterId"] = plan.get("character") + elif character != identity(original.get("ownerCharacterId"), nullable=True): + raise ValueError("Mode-only plan changed its owner") + if "staffMint" in plan: + if original.get("staffMint") is not None or type(plan["staffMint"]) is not bool: + raise ValueError("Invalid frozen legacy mode") + expected["staffMint"] = plan["staffMint"] + elif not owner_change: + raise ValueError("Empty migration plan") + if identity(plan.get("account"), nullable=True) != account or not same(expected, plan.get("planned")): + raise ValueError("Frozen mapping/ownership output requires review") + if digest(current) == args.before: + print("Already restored; no changes") + return + if digest(current) != plan.get("after") or not same(load(current), plan["planned"]): + raise ValueError("CONFLICT: current table contains intervening progress or ownership changes") + for journal in (root / "estates/tables").glob("*.json"): + candidates = load(journal.read_bytes()).get("candidates") + if not isinstance(candidates, list): + raise ValueError("Estate intent/receipt requires review") + candidates = [identity(candidate) for candidate in candidates] + if len(set(candidates)) != len(candidates): + raise ValueError("Duplicate estate intent/receipt IDs") + if table_id in candidates: + raise ValueError("CONFLICT: this table has estate intent/receipt history") + print("No conflicts; verified immutable source and exact migrated table") + if not args.apply: + print("Preview only; stop Games cleanly before applying") + return + if (root / "ownership-active").exists(): + raise ValueError("Games has not stopped cleanly; retain the marker and review recovery") + preserved = source.with_name(source.name + ".pre-ownership-rollback") + if preserved.exists() and preserved.read_bytes() != current: + raise ValueError("Existing pre-rollback preservation file differs") + if not preserved.exists(): + publish(preserved, current) + publish(source, archive) + print("Restored exact original; current file preserved beside it") + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, KeyError, TypeError) as error: + raise SystemExit(str(error))