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