Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
71 changes: 71 additions & 0 deletions integration-tests/LegacyApiCheck.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<denareconomy.version>0.3.0</denareconomy.version>
<rpcharacters.version>2.16.0</rpcharacters.version>
<simplefactions.version>4.0.0</simplefactions.version>
<tlibs.version>2.4.0</tlibs.version>
<tlibs.version>2.6.0</tlibs.version>
</properties>

<dependencyManagement>
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/net/tfminecraft/games/Games.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand All @@ -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();

Expand Down Expand Up @@ -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.");
}

Expand Down
5 changes: 4 additions & 1 deletion src/main/java/net/tfminecraft/games/gui/GameSelectGui.java
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/net/tfminecraft/games/gui/GameSelectHolder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 19 additions & 6 deletions src/main/java/net/tfminecraft/games/gui/TableOptionsGui.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public final class TableOptionsGui implements Listener {
private static final long CHAT_TICKS = 20L * 60;

private static final Map<UUID, ChatPrompt> prompts = new ConcurrentHashMap<>();
public static void clear(UUID account) { prompts.remove(account); }

private TableOptionsGui() {}

Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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));
Expand All @@ -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);
}

Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/net/tfminecraft/games/guild/GuildTables.java
Original file line number Diff line number Diff line change
Expand Up @@ -189,14 +189,16 @@ 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;
}
String guildId = table.ownerGuildId();
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. */
Expand Down
Loading
Loading