From f9dd73c18f5637bb827ad454655121c76a30faaf Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Mon, 12 Oct 2026 19:05:09 +0000
Subject: [PATCH 1/5] feat: bind game money to character and guild ledgers
---
pom.xml | 6 +-
.../java/net/tfminecraft/games/Games.java | 2 +
.../games/guild/GuildTableMigration.java | 38 +++
.../tfminecraft/games/guild/GuildTables.java | 53 +++-
.../games/persistence/DurableFiles.java | 46 ++++
.../net/tfminecraft/games/table/Table.java | 20 ++
.../tfminecraft/games/table/TableHouse.java | 12 +
.../tfminecraft/games/table/TableManager.java | 93 ++++++-
.../net/tfminecraft/games/wager/Accounts.java | 5 -
.../tfminecraft/games/wager/BankAccount.java | 41 ++-
.../games/wager/BucketAccount.java | 22 ++
.../tfminecraft/games/wager/CitizenTax.java | 68 ++---
.../tfminecraft/games/wager/GuildBank.java | 121 ++------
.../tfminecraft/games/wager/MoneyAccount.java | 6 +
.../net/tfminecraft/games/wager/MoneyTx.java | 150 +++++++++-
.../games/wager/PlayerAccount.java | 43 ++-
.../net/tfminecraft/games/wager/TaxSink.java | 65 ++---
.../tfminecraft/games/wager/WagerEngine.java | 93 ++++---
.../tfminecraft/games/wager/WagerJournal.java | 116 ++++++++
src/main/resources/plugin.yml | 4 +-
.../games/guild/GuildTableMigrationTest.java | 81 ++++++
.../games/guild/GuildTablesTest.java | 60 +++-
.../games/support/TestCharacters.java | 30 ++
.../games/table/TableManagerActionTest.java | 5 +-
.../table/TableManagerBlackjackRoundTest.java | 18 +-
.../table/TableManagerChipLayoutTest.java | 2 +
.../games/table/TableManagerFixture.java | 10 +
.../table/TableManagerInteractionTest.java | 4 +-
.../table/TableManagerLifecycleTest.java | 3 +
.../table/TableManagerPersistenceTest.java | 42 ++-
.../games/table/TableManagerSettleTest.java | 18 +-
.../table/TableManagerStoredDataTest.java | 3 +
.../games/table/TableManagerWagerTest.java | 28 +-
.../tfminecraft/games/wager/AccountsTest.java | 6 +-
.../games/wager/BankAccountTest.java | 23 +-
.../wager/CitizenTaxIntegrationTest.java | 57 ++--
.../games/wager/CitizenTaxTest.java | 6 +-
.../games/wager/GuildBankTest.java | 187 +++++--------
.../tfminecraft/games/wager/MoneyTxTest.java | 1 +
.../games/wager/WagerEngineTest.java | 102 +++++--
.../games/wager/WagerJournalTest.java | 258 ++++++++++++++++++
41 files changed, 1459 insertions(+), 489 deletions(-)
create mode 100644 src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java
create mode 100644 src/main/java/net/tfminecraft/games/persistence/DurableFiles.java
create mode 100644 src/main/java/net/tfminecraft/games/wager/WagerJournal.java
create mode 100644 src/test/java/net/tfminecraft/games/guild/GuildTableMigrationTest.java
create mode 100644 src/test/java/net/tfminecraft/games/support/TestCharacters.java
create mode 100644 src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java
diff --git a/pom.xml b/pom.xml
index b1e48fd..d622583 100644
--- a/pom.xml
+++ b/pom.xml
@@ -12,10 +12,10 @@
21
UTF-8
full
- 0.2.0
+ 0.3.0
2.16.0
- 3.0.1
- 2.3.0
+ 4.0.0
+ 2.4.0
diff --git a/src/main/java/net/tfminecraft/games/Games.java b/src/main/java/net/tfminecraft/games/Games.java
index 4ea5d20..34dfa05 100644
--- a/src/main/java/net/tfminecraft/games/Games.java
+++ b/src/main/java/net/tfminecraft/games/Games.java
@@ -37,6 +37,7 @@ public class Games extends JavaPlugin {
@Override
public void onEnable() {
plugin = this;
+ net.tfminecraft.games.wager.WagerJournal.initialize();
createFolders();
createConfigs();
if (!loadConfigs()) {
@@ -49,6 +50,7 @@ public void onEnable() {
getServer().getPluginManager().registerEvents(GameSelectGui.INSTANCE, this);
getServer().getPluginManager().registerEvents(TableOptionsGui.INSTANCE, this);
TableManager.get().loadAll();
+ getServer().getScheduler().runTaskTimer(this, () -> TableManager.get().loadDeferred(), 100L, 100L);
TableManager.get().startClock();
var gameCmd = getCommand("games");
diff --git a/src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java b/src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java
new file mode 100644
index 0000000..0debb84
--- /dev/null
+++ b/src/main/java/net/tfminecraft/games/guild/GuildTableMigration.java
@@ -0,0 +1,38 @@
+package net.tfminecraft.games.guild;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.UUID;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import net.tfminecraft.games.persistence.DurableFiles;
+
+/** Stamp an old table's guild incarnation once; a deleted guild never adopts a later namesake. */
+public final class GuildTableMigration {
+ private GuildTableMigration() {}
+
+ public static boolean prepare(Path tableFile, Path migrationFolder) throws IOException {
+ JsonObject data = JsonParser.parseString(Files.readString(tableFile)).getAsJsonObject();
+ if (data.has("ownerGuildMigrated") && data.get("ownerGuildMigrated").getAsBoolean()) return true;
+ if (!data.has("ownerGuildId") || data.get("ownerGuildId").isJsonNull()
+ || data.get("ownerGuildId").getAsString().isBlank()) return true;
+ if (!GuildTables.ready()) return false;
+ Path backup = migrationFolder.resolve("backup").resolve(tableFile.getFileName());
+ Path manifest = migrationFolder.resolve(tableFile.getFileName());
+ JsonObject migrated;
+ if (Files.exists(manifest)) {
+ // A complete plan is written before the table. Replay the exact incarnation, never resolve again.
+ migrated = JsonParser.parseString(Files.readString(manifest)).getAsJsonObject();
+ } else {
+ if (!Files.exists(backup)) DurableFiles.write(backup, Files.readAllBytes(tableFile));
+ migrated = data.deepCopy();
+ UUID incarnation = GuildTables.incarnation(data.get("ownerGuildId").getAsString());
+ if (incarnation != null) migrated.addProperty("ownerGuildIncarnation", incarnation.toString());
+ migrated.addProperty("ownerGuildMigrated", true);
+ DurableFiles.write(manifest, migrated.toString());
+ }
+ DurableFiles.write(tableFile, migrated.toString());
+ return true;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/games/guild/GuildTables.java b/src/main/java/net/tfminecraft/games/guild/GuildTables.java
index a89ec54..be2ce70 100644
--- a/src/main/java/net/tfminecraft/games/guild/GuildTables.java
+++ b/src/main/java/net/tfminecraft/games/guild/GuildTables.java
@@ -1,12 +1,13 @@
package net.tfminecraft.games.guild;
+import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import net.tfminecraft.simplefactions.guild.Guild;
import net.tfminecraft.simplefactions.managers.FactionManager;
-import net.tfminecraft.simplefactions.objects.Bank;
+import net.tfminecraft.simplefactions.api.SimpleFactionsApi;
import net.tfminecraft.simplefactions.enums.GuildModifier;
import net.tfminecraft.games.Games;
import net.tfminecraft.games.Messages;
@@ -53,7 +54,7 @@ public static String guildId(Player player) {
if (!sfReady()) {
return null;
}
- Guild guild = FactionManager.getGuildByMember(player.getName());
+ Guild guild = FactionManager.getGuildByMember(player);
if (guild == null) {
return null;
}
@@ -100,8 +101,10 @@ public static int cap(String guildId) {
public static int count(String guildId) {
int n = 0;
+ UUID current = incarnation(guildId);
for (Table table : TableManager.get().tables()) {
- if (counts(table) && guildId.equals(table.ownerGuildId())) {
+ if (counts(table) && guildId.equals(table.ownerGuildId())
+ && current != null && current.equals(table.ownerGuildIncarnation())) {
n++;
}
}
@@ -117,10 +120,12 @@ public static boolean overCap(String guildId) {
}
public static boolean frozen(Table table) {
- return counts(table) && overCap(table.ownerGuildId());
+ return counts(table) && (!owns(table.ownerGuildId(), table.ownerGuildIncarnation())
+ || overCap(table.ownerGuildId()));
}
public static boolean canStartGuildAutoRound(Table table) {
+ if (counts(table) && !owns(table.ownerGuildId(), table.ownerGuildIncarnation())) return false;
if (table.live()) {
return true;
}
@@ -145,7 +150,9 @@ public static String refuseKey(Player player, TableHouse house, Table existing)
if (id == null) {
return "place.no_guild";
}
- if (!isLeader(id, player)) {
+ if (existing == null && house.ownerGuildIncarnation() == null)
+ house.setOwnerGuildIncarnation(incarnation(id));
+ if (!owns(id, house.ownerGuildIncarnation()) || !isLeader(id, player)) {
return "place.not_leader";
}
if (existing != null && counts(existing)) {
@@ -174,6 +181,7 @@ public static void stampGuild(Player player, TableHouse house) {
}
if (house.ownerGuildId() == null || house.ownerGuildId().isBlank()) {
house.setOwnerGuildId(guildId(player));
+ house.setOwnerGuildIncarnation(incarnation(house.ownerGuildId()));
}
}
@@ -186,7 +194,7 @@ public static boolean mayDeal(Table table, Player player) {
}
String guildId = table.ownerGuildId();
if (guildId != null && !guildId.isBlank()) {
- return isMember(guildId, player);
+ return owns(guildId, table.ownerGuildIncarnation()) && isMember(guildId, player);
}
return table.ownerPlayer() != null && table.ownerPlayer().equals(player.getUniqueId());
}
@@ -194,7 +202,31 @@ public static boolean mayDeal(Table table, Player player) {
/** True for the leader of the guild that owns this table, who is the one who may place and remove it. */
public static boolean leadsOwner(Table table, Player player) {
String guildId = table.ownerGuildId();
- return guildId != null && !guildId.isBlank() && isLeader(guildId, player);
+ return owns(guildId, table.ownerGuildIncarnation()) && isLeader(guildId, player);
+ }
+
+ public static UUID incarnation(String guildId) {
+ if (guildId == null || guildId.isBlank()) return null;
+ try {
+ Guild guild = guild(guildId);
+ return guild == null ? null : guild.getIncarnationId();
+ } catch (RuntimeException | LinkageError ex) {
+ warn(ex);
+ return null;
+ }
+ }
+
+ public static boolean owns(String guildId, UUID incarnation) {
+ return incarnation != null && incarnation.equals(incarnation(guildId));
+ }
+
+ public static boolean ready() {
+ try {
+ return sfReady();
+ } catch (RuntimeException | LinkageError ex) {
+ warn(ex);
+ return false;
+ }
}
private static boolean isMember(String guildId, Player player) {
@@ -213,8 +245,7 @@ private static boolean isLeader(String guildId, Player player) {
if (guild == null) {
return false;
}
- String leader = guild.getLeader();
- return leader != null && leader.equalsIgnoreCase(player.getName());
+ return guild.isLeader(player);
} catch (RuntimeException | LinkageError ex) {
warn(ex);
return false;
@@ -233,13 +264,13 @@ private static Guild guild(String guildId) {
private static boolean sfReady() {
Plugin plugin = Bukkit.getPluginManager().getPlugin("SimpleFactions");
- return plugin != null && plugin.isEnabled();
+ return plugin != null && plugin.isEnabled() && SimpleFactionsApi.ready();
}
private static void warn(Throwable ex) {
if (!loggedFail) {
loggedFail = true;
- Games.plugin.getLogger().warning("[Games] SimpleFactions guild tables skipped: " + ex.getMessage());
+ if (Games.plugin != null) Games.plugin.getLogger().warning("[Games] SimpleFactions guild tables skipped: " + ex.getMessage());
}
}
}
diff --git a/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java b/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java
new file mode 100644
index 0000000..dc8679d
--- /dev/null
+++ b/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java
@@ -0,0 +1,46 @@
+package net.tfminecraft.games.persistence;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+
+/** Publish whole, flushed files before money or inventory changes. */
+public final class DurableFiles {
+ private DurableFiles() {}
+
+ public static void write(Path target, String text) throws IOException {
+ write(target, text.getBytes(StandardCharsets.UTF_8));
+ }
+
+ public static void write(Path target, byte[] bytes) throws IOException {
+ Files.createDirectories(target.getParent());
+ Path temp = Files.createTempFile(target.getParent(), ".pending-", ".tmp");
+ try {
+ try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
+ ByteBuffer buffer = ByteBuffer.wrap(bytes);
+ while (buffer.hasRemaining()) channel.write(buffer);
+ channel.force(true);
+ }
+ Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ sync(target.getParent());
+ } finally {
+ Files.deleteIfExists(temp);
+ }
+ }
+
+ public static void delete(Path target) throws IOException {
+ Files.deleteIfExists(target);
+ sync(target.getParent());
+ }
+
+ private static void sync(Path directory) throws IOException {
+ try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
+ channel.force(true);
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/games/table/Table.java b/src/main/java/net/tfminecraft/games/table/Table.java
index bf2ad38..6a5c6ff 100644
--- a/src/main/java/net/tfminecraft/games/table/Table.java
+++ b/src/main/java/net/tfminecraft/games/table/Table.java
@@ -60,7 +60,15 @@ public final class Table {
private int maxBet;
private int autoCountdown;
private UUID ownerPlayer;
+ private boolean moneyBlocked;
+ private final java.util.Map wagerCharacters = new java.util.HashMap<>();
+
+ public boolean moneyBlocked() { return moneyBlocked; }
+ public void setMoneyBlocked(boolean blocked) { moneyBlocked = blocked; }
+ public java.util.Map wagerCharacters() { return wagerCharacters; }
+
private String ownerGuildId;
+ private UUID ownerGuildIncarnation;
private boolean autoDealer;
private boolean staffMint;
private int houseFloat;
@@ -373,9 +381,18 @@ public String ownerGuildId() {
}
public void setOwnerGuildId(String ownerGuildId) {
+ if (!java.util.Objects.equals(this.ownerGuildId, ownerGuildId)) ownerGuildIncarnation = null;
this.ownerGuildId = ownerGuildId;
}
+ public UUID ownerGuildIncarnation() {
+ return ownerGuildIncarnation;
+ }
+
+ public void setOwnerGuildIncarnation(UUID incarnation) {
+ this.ownerGuildIncarnation = incarnation;
+ }
+
public boolean autoDealer() {
return autoDealer;
}
@@ -481,6 +498,7 @@ public void startSession() {
}
public void clearSession() {
+ if (moneyBlocked || net.tfminecraft.games.wager.WagerJournal.blocked(this)) return;
live = false;
actor = null;
phase = null;
@@ -489,5 +507,7 @@ public void clearSession() {
handIndex = 0;
autoCountdown = 0;
if (!poker.enabled() || !poker.occupied()) roundMoney.clear();
+ wagerCharacters.entrySet().removeIf(entry -> ledger.total(entry.getKey()) == 0
+ && !(ledger.total(id) > 0 && (entry.getKey().equals(dealerId) || entry.getKey().equals(floatOwner))));
}
}
diff --git a/src/main/java/net/tfminecraft/games/table/TableHouse.java b/src/main/java/net/tfminecraft/games/table/TableHouse.java
index 7a56851..f97eed3 100644
--- a/src/main/java/net/tfminecraft/games/table/TableHouse.java
+++ b/src/main/java/net/tfminecraft/games/table/TableHouse.java
@@ -15,6 +15,7 @@ public final class TableHouse {
private UUID ownerPlayer;
private String ownerGuildId;
+ private UUID ownerGuildIncarnation;
private boolean autoDealer;
private boolean staffMint;
private int minBet;
@@ -53,6 +54,7 @@ public static TableHouse from(Table table) {
TableHouse house = new TableHouse();
house.ownerPlayer = table.ownerPlayer();
house.ownerGuildId = table.ownerGuildId();
+ house.ownerGuildIncarnation = table.ownerGuildIncarnation();
house.autoDealer = table.autoDealer();
house.staffMint = table.staffMint();
house.minBet = Math.max(1, table.minBet());
@@ -67,6 +69,7 @@ public static TableHouse from(Table table) {
public void apply(Table table) {
table.setOwnerPlayer(ownerPlayer);
table.setOwnerGuildId(ownerGuildId);
+ table.setOwnerGuildIncarnation(ownerGuildIncarnation);
table.setAutoDealer(autoDealer);
table.setStaffMint(staffMint);
table.setMinBet(minBet);
@@ -90,9 +93,18 @@ public String ownerGuildId() {
}
public void setOwnerGuildId(String ownerGuildId) {
+ if (!java.util.Objects.equals(this.ownerGuildId, ownerGuildId)) ownerGuildIncarnation = null;
this.ownerGuildId = ownerGuildId;
}
+ public UUID ownerGuildIncarnation() {
+ return ownerGuildIncarnation;
+ }
+
+ public void setOwnerGuildIncarnation(UUID incarnation) {
+ this.ownerGuildIncarnation = incarnation;
+ }
+
public boolean autoDealer() {
return autoDealer;
}
diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java
index 7a00ffa..f6a2b51 100644
--- a/src/main/java/net/tfminecraft/games/table/TableManager.java
+++ b/src/main/java/net/tfminecraft/games/table/TableManager.java
@@ -272,24 +272,47 @@ public void selectGame(Player player, String gameId, Location at, boolean requir
player.sendMessage(Messages.get(requireDeck ? "place.armed" : "place.armed_admin"));
}
- public void loadAll() {
+ public void loadAll() { loadTables(false); }
+
+ public void loadDeferred() { loadTables(true); }
+
+ private void loadTables(boolean onlyMissing) {
File folder = tablesFolder();
File[] files = folder.listFiles((dir, name) -> name.endsWith(".json"));
if (files == null) {
return;
}
for (File file : files) {
+ try {
+ if (onlyMissing) {
+ try {
+ UUID fileId = UUID.fromString(file.getName().replace(".json", ""));
+ if (tables.containsKey(fileId)) continue;
+ } catch (IllegalArgumentException ignored) { /* Older/imported files may have another filename. */ }
+ }
+ if (!net.tfminecraft.games.guild.GuildTableMigration.prepare(file.toPath(),
+ new File(Games.plugin.getDataFolder(), "migrations/guild-tables").toPath())) continue;
+ } catch (IOException | RuntimeException ex) {
+ Games.plugin.getLogger().warning("[Games] Failed to load table " + file.getName() + ": " + ex);
+ continue;
+ }
try (FileReader reader = new FileReader(file, StandardCharsets.UTF_8)) {
TableData data = GSON.fromJson(reader, TableData.class);
if (data == null || data.id == null) {
continue;
}
+ if (onlyMissing && tables.containsKey(playerId(data.id))) continue;
List unowned = new ArrayList<>();
Table table = fromData(data, unowned);
if (table == null) {
continue;
}
tables.put(table.getId(), table);
+ if (net.tfminecraft.games.wager.WagerJournal.blocked(table)) {
+ table.setMoneyBlocked(true);
+ Games.plugin.getLogger().severe("[Games] Interrupted money operation freezes table " + table.getId());
+ continue;
+ }
migrateLegacyPiles(table, data, unowned);
int unownedDenars = unowned.stream().mapToInt(Stake::value).sum();
LedgerAudit.checkLoaded(table, storedDenars(data), unownedDenars);
@@ -330,6 +353,7 @@ public void despawnWorldAll() {
* End the session, settle money, muck cards, full idle deck. Table file stays.
*/
private void resetTableToIdle(Table table, Location dropAt) {
+ if (table.moneyBlocked() || net.tfminecraft.games.wager.WagerJournal.blocked(table)) return;
cancelVote(table, null);
cancelLootArmsForTable(table.getId());
table.bumpRecycleGen();
@@ -350,7 +374,9 @@ private void resetTableToIdle(Table table, Location dropAt) {
// being dropped at an empty table on shutdown.
settleAbsentFloat(table);
}
+ if (moneyBlocked(table)) return;
clearFeltNow(table);
+ if (moneyBlocked(table)) return;
table.actives().clear();
table.setDealerId(null);
table.setStreet(1);
@@ -725,12 +751,15 @@ public void onChunkLoad(ChunkLoadEvent event) {
}
private void pickup(Player player, Table table) {
+ if (table.moneyBlocked() || net.tfminecraft.games.wager.WagerJournal.blocked(table)) return;
cancelVote(table, "wager.cancelled");
table.bumpRecycleGen();
cancelLootArmsForTable(table.getId());
Location dropAt = table.getOrigin().clone();
settleAutoTray(table, dropAt);
+ if (moneyBlocked(table)) return;
clearFeltNow(table);
+ if (moneyBlocked(table)) return;
returnAllHands(table, false, false);
table.actives().clear();
table.clearSession();
@@ -1587,12 +1616,24 @@ public void bankAutoTray(Table table) {
}
public void beginSession(Table table) {
+ if (table.moneyBlocked() || net.tfminecraft.games.wager.WagerJournal.blocked(table) || !net.tfminecraft.denareconomy.api.Denars.taxReady()) return;
if (table.live()) {
return;
}
if (!GuildTables.canStartGuildAutoRound(table)) {
return;
}
+ java.util.Set participants = new java.util.LinkedHashSet<>(table.actives());
+ if (table.dealerId() != null) participants.add(table.dealerId());
+ for (UUID owner : participants) {
+ Player active = Bukkit.getPlayer(owner);
+ var captured = table.wagerCharacters().get(owner);
+ var current = active == null ? null : net.tfminecraft.tlibs.character.Characters.currentSession(active)
+ .map(session -> session.ref()).orElse(null);
+ if (current == null && captured == null) return;
+ if (current != null && captured != null && !current.equals(captured)) return;
+ if (current != null) table.wagerCharacters().putIfAbsent(owner, current);
+ }
table.startSession();
Game game = gameOf(table);
if (game != null) {
@@ -1756,6 +1797,7 @@ public void muckTable(Table table, String name) {
}
public void endSession(Table table) {
+ if (table.moneyBlocked() || net.tfminecraft.games.wager.WagerJournal.blocked(table)) return;
table.clearSession();
checkFeltEmpty(table, "session end");
despawnTablePiles(table);
@@ -1809,9 +1851,11 @@ private void dealRemaining(Table table, Player player, int left, int slot, Runna
}
public boolean canEditHouse(Player player, Table table) {
+ if (table.moneyBlocked() || net.tfminecraft.games.wager.WagerJournal.blocked(table)) return false;
if (player.hasPermission(TableHouse.STAFF_PERM) || player.hasPermission("games.admin")) {
return true;
}
+ if (GuildTables.counts(table)) return GuildTables.leadsOwner(table, player);
UUID owner = table.ownerPlayer();
return owner != null && owner.equals(player.getUniqueId());
}
@@ -1867,8 +1911,10 @@ public void persistHouseChange(Table table) {
public void applyHouse(Table table, TableHouse house) {
String wasGuild = table.ownerGuildId();
+ UUID wasIncarnation = table.ownerGuildIncarnation();
house.apply(table);
- if (!Objects.equals(wasGuild, table.ownerGuildId())) {
+ if (!Objects.equals(wasGuild, table.ownerGuildId())
+ || !Objects.equals(wasIncarnation, table.ownerGuildIncarnation())) {
// A float belongs to the guild that put it up, so it cannot follow the table to a new
// owner and shelter their winnings from tax.
table.setHouseFloat(0);
@@ -1904,6 +1950,13 @@ private static void applyHouseData(Table table, TableData data) {
table.setFloatOwner(playerId(data.floatOwner));
table.setDeckConsumed(!Boolean.FALSE.equals(data.deckConsumed));
table.setOwnerGuildId(data.ownerGuildId);
+ table.setOwnerGuildIncarnation(playerId(data.ownerGuildIncarnation));
+ if (data.wagerCharacters != null) data.wagerCharacters.forEach((owner, character) -> {
+ UUID ownerId = playerId(owner);
+ UUID characterId = playerId(character);
+ if (ownerId != null && characterId != null) table.wagerCharacters().put(ownerId,
+ new net.tfminecraft.tlibs.character.CharacterRef(ownerId, characterId));
+ });
table.setHouseFloat(data.houseFloat != null ? data.houseFloat : 0);
if (data.autoDealer == null) {
boolean yaml = layout != null && layout.autoDealer();
@@ -3071,24 +3124,39 @@ private void clearFeltNow(Table table) {
* Empty the tray. Bank money the house put there, give a human dealer their own float back,
* and delete a staff mint float.
*/
+ private static boolean moneyBlocked(Table table) {
+ return table.moneyBlocked() || net.tfminecraft.games.wager.WagerJournal.blocked(table);
+ }
+
private void settleAutoTray(Table table, Location dropAt) {
UUID house = table.getId();
if (table.ledger().total(house) < 1) {
return;
}
+ if (GuildTables.counts(table) && (!GuildTables.owns(table.ownerGuildId(), table.ownerGuildIncarnation())
+ || !net.tfminecraft.denareconomy.api.Denars.taxReady())) {
+ net.tfminecraft.games.wager.WagerJournal.quarantine(table, "Unresolved guild tray settlement: guild="
+ + table.ownerGuildId() + ", incarnation=" + table.ownerGuildIncarnation() + ", value=" + trayDenars(table));
+ return;
+ }
MoneyAccount tray = Accounts.tray(table);
MoneyAccount to;
String reason;
if (!GuildTables.houseBacked(table)) {
// Nothing behind this table but its dealer, so the float they stocked goes back to them.
Player dealer = table.dealerId() != null ? Bukkit.getPlayer(table.dealerId()) : null;
- to = Accounts.payee(table, dealer, house);
+ to = dealer == null ? Accounts.ground(table, dropAt) : Accounts.payee(table, dealer, dealer.getUniqueId());
reason = "dealer float returned";
} else {
to = Accounts.house(table);
reason = "tray settled";
}
TxResult result = wager().begin(table, reason).moveAll(tray, to).commit();
+ if (!result.ok() && GuildTables.counts(table)) {
+ net.tfminecraft.games.wager.WagerJournal.quarantine(table, "Unpaid guild tray settlement: guild="
+ + table.ownerGuildIncarnation() + ", value=" + trayDenars(table));
+ return;
+ }
if (!result.ok() || result.moved() < 1) {
// Nowhere to bank it, so it goes back out as coins rather than being lost.
wager().begin(table, "tray dropped, no guild bank")
@@ -3127,10 +3195,10 @@ private void settleAbsentFloat(Table table) {
/** The whole tray to one player who is online, and nobody owed it any more. */
private void payTray(Table table, Player to) {
- table.setFloatOwner(null);
- wager().begin(table, "dealer float returned")
+ TxResult paid = wager().begin(table, "dealer float returned")
.moveAll(Accounts.tray(table), Accounts.payee(table, to, to.getUniqueId()))
.commit();
+ if (paid.ok() && trayDenars(table) == 0) table.setFloatOwner(null);
wager().forget(table, table.getId());
save(table);
}
@@ -4369,11 +4437,15 @@ private Table tableFrom(Entity entity) {
}
}
+ public void saveWager(Table table) throws IOException {
+ net.tfminecraft.games.persistence.DurableFiles.write(tableFile(table.getId()).toPath(), GSON.toJson(toData(table)));
+ }
+
private void save(Table table) {
File file = tableFile(table.getId());
TableData data = toData(table);
- try (FileWriter writer = new FileWriter(file, StandardCharsets.UTF_8)) {
- GSON.toJson(data, writer);
+ try {
+ net.tfminecraft.games.persistence.DurableFiles.write(file.toPath(), GSON.toJson(data));
} catch (IOException ex) {
Games.plugin.getLogger().warning("[Games] Failed to save table " + table.getId() + ": " + ex.getMessage());
}
@@ -4427,6 +4499,10 @@ private static TableData toData(Table table) {
}
data.ownerPlayer = table.ownerPlayer() != null ? table.ownerPlayer().toString() : null;
data.ownerGuildId = table.ownerGuildId();
+ data.ownerGuildIncarnation = table.ownerGuildIncarnation() == null ? null : table.ownerGuildIncarnation().toString();
+ data.ownerGuildMigrated = true;
+ data.wagerCharacters = new HashMap<>();
+ table.wagerCharacters().forEach((owner, character) -> data.wagerCharacters.put(owner.toString(), character.characterId().toString()));
data.autoDealer = table.autoDealer();
data.staffMint = table.staffMint();
data.houseFloat = table.houseFloat();
@@ -4702,6 +4778,9 @@ static final class TableData {
List piles = new ArrayList<>();
String ownerPlayer;
String ownerGuildId;
+ String ownerGuildIncarnation;
+ Boolean ownerGuildMigrated;
+ Map wagerCharacters;
Boolean autoDealer;
Boolean staffMint;
/** Bank money still out on the felt. Absent on files written before profits were taxed. */
diff --git a/src/main/java/net/tfminecraft/games/wager/Accounts.java b/src/main/java/net/tfminecraft/games/wager/Accounts.java
index a2e698f..b60c17d 100644
--- a/src/main/java/net/tfminecraft/games/wager/Accounts.java
+++ b/src/main/java/net/tfminecraft/games/wager/Accounts.java
@@ -82,11 +82,6 @@ public static MintAccount mint(Table table) {
return new MintAccount(table, table.street());
}
- /** Where withheld citizen tax chips are destroyed. Not on the felt and not in a bank. */
- public static MoneyAccount taxSink() {
- return TaxSink.INSTANCE;
- }
-
/**
* Wherever this table's house money comes from: the mint on a staff table, the guild bank
* on a real one. Saves every caller repeating that branch and getting it slightly different.
diff --git a/src/main/java/net/tfminecraft/games/wager/BankAccount.java b/src/main/java/net/tfminecraft/games/wager/BankAccount.java
index 3f57f58..0561580 100644
--- a/src/main/java/net/tfminecraft/games/wager/BankAccount.java
+++ b/src/main/java/net/tfminecraft/games/wager/BankAccount.java
@@ -16,12 +16,28 @@ public final class BankAccount implements MoneyAccount {
private final Table table;
private final int streetId;
+ private Integer planningBalance;
+ private int authorizedDebit;
+ private int authorizedCredit;
BankAccount(Table table, int streetId) {
this.table = table;
this.streetId = streetId;
}
+ @Override
+ public MoneyAccount planningCopy() {
+ BankAccount copy = new BankAccount(table, streetId);
+ copy.planningBalance = available();
+ return copy;
+ }
+
+ @Override
+ public String ledgerAccount() { return GuildBank.account(table); }
+
+ void authorizeDebit(int denars) { authorizedDebit = Math.addExact(authorizedDebit, denars); }
+ void authorizeCredit(int denars) { authorizedCredit = Math.addExact(authorizedCredit, denars); }
+
@Override
public String label() {
return "guild bank";
@@ -29,7 +45,7 @@ public String label() {
@Override
public int available() {
- return GuildBank.balance(table.ownerGuildId());
+ return planningBalance != null ? planningBalance : GuildBank.balance(table);
}
@Override
@@ -61,7 +77,7 @@ public int largestTakeUpTo(int denars, ItemStack template) {
@Override
public boolean canAccept(int denars) {
- return denars < 1 || GuildBank.canHold(table.ownerGuildId());
+ return denars < 1 || GuildBank.canHold(table);
}
@Override
@@ -70,17 +86,10 @@ public int accept(List stakes) {
if (value < 1) {
return 0;
}
- if (!GuildBank.deposit(table.ownerGuildId(), value)) {
- return 0;
- }
- // The float comes home before anything is called a win, so a table that has been losing
- // has to earn its way back to level before the guild owes tax on any of it.
+ if (authorizedCredit < value) return 0;
+ authorizedCredit -= value;
int repaid = Math.min(value, table.houseFloat());
table.setHouseFloat(table.houseFloat() - repaid);
- int profit = value - repaid;
- if (profit > 0) {
- GuildBank.declareProfit(table.ownerGuildId(), profit);
- }
return value;
}
@@ -110,10 +119,14 @@ public List preview() {
@Override
public List take() {
- if (!GuildBank.withdraw(table.ownerGuildId(), denars)) {
- return new ArrayList<>();
+ if (planningBalance != null) {
+ if (planningBalance < denars) return new ArrayList<>();
+ planningBalance -= denars;
+ return coins();
}
- table.setHouseFloat(table.houseFloat() + denars);
+ if (authorizedDebit < denars) return new ArrayList<>();
+ authorizedDebit -= denars;
+ table.setHouseFloat(Math.addExact(table.houseFloat(), denars));
return coins();
}
diff --git a/src/main/java/net/tfminecraft/games/wager/BucketAccount.java b/src/main/java/net/tfminecraft/games/wager/BucketAccount.java
index e0f994d..328d40d 100644
--- a/src/main/java/net/tfminecraft/games/wager/BucketAccount.java
+++ b/src/main/java/net/tfminecraft/games/wager/BucketAccount.java
@@ -53,6 +53,28 @@ public UUID owner() {
return owner;
}
+ Object snapshot() {
+ java.util.List