From 31ccdc2d67a47c0fd0fd696a8ab0e40626b08233 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:53:16 +0200 Subject: [PATCH 1/5] Draw coin piles beside cards instead of over them Chip models stand taller than a flat card, so coins put on (or dealt under) a board card hid it. Piles are now drawn at the nearest spot that leaves the shoe, the discard and every table pile card showing, staying on the felt (or in the tray) they were put on. The ledger spot does not change: once the cards leave, the coins slide back to where they were put, and clicking a pile drawn beside a card adds to that same heap. wager.card-gap (default 0.1 blocks) sets the clearance; 0 keeps the old behaviour. Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/games/Games.java | 1 + .../net/tfminecraft/games/cache/Cache.java | 1 + .../games/layout/FeltClearance.java | 119 ++++++++++ .../games/loader/ConfigLoader.java | 2 + .../tfminecraft/games/table/TableManager.java | 125 ++++++++++- .../net/tfminecraft/games/wager/PotPile.java | 13 ++ src/main/resources/config.yml | 3 + .../games/layout/FeltClearanceTest.java | 105 +++++++++ .../games/table/TableManagerCardGapTest.java | 205 ++++++++++++++++++ 9 files changed, 569 insertions(+), 5 deletions(-) create mode 100644 src/main/java/net/tfminecraft/games/layout/FeltClearance.java create mode 100644 src/test/java/net/tfminecraft/games/layout/FeltClearanceTest.java create mode 100644 src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java diff --git a/src/main/java/net/tfminecraft/games/Games.java b/src/main/java/net/tfminecraft/games/Games.java index e3bbfdd..a2a36ff 100644 --- a/src/main/java/net/tfminecraft/games/Games.java +++ b/src/main/java/net/tfminecraft/games/Games.java @@ -158,6 +158,7 @@ private boolean loadConfigs() { + " wager.y-offset=" + Cache.wagerYOffset + " wager.random-yaw=" + Cache.wagerRandomYaw + " wager.merge-range=" + Cache.wagerMergeRange + + " wager.card-gap=" + Cache.wagerCardGap + " wager.vote-seconds=" + Cache.wagerVoteSeconds + " wager.place-seconds=" + Cache.wagerPlaceSeconds + " wager.payout-ticks=" + Cache.wagerPayoutTicks diff --git a/src/main/java/net/tfminecraft/games/cache/Cache.java b/src/main/java/net/tfminecraft/games/cache/Cache.java index f085c26..c96316e 100644 --- a/src/main/java/net/tfminecraft/games/cache/Cache.java +++ b/src/main/java/net/tfminecraft/games/cache/Cache.java @@ -70,6 +70,7 @@ public final class Cache { public static float wagerItemScale = 0.35f; public static boolean wagerRandomYaw = false; public static double wagerMergeRange = 0.1; + public static double wagerCardGap = 0.1; public static double wagerYOffset = 0; public static int wagerVoteSeconds = 30; public static int wagerPlaceSeconds = 10; diff --git a/src/main/java/net/tfminecraft/games/layout/FeltClearance.java b/src/main/java/net/tfminecraft/games/layout/FeltClearance.java new file mode 100644 index 0000000..2c4a630 --- /dev/null +++ b/src/main/java/net/tfminecraft/games/layout/FeltClearance.java @@ -0,0 +1,119 @@ +package net.tfminecraft.games.layout; + +import java.util.List; + +import org.joml.Vector3f; + +import net.tfminecraft.games.display.DisplayPose; + +/** + * Keeps chip piles off the cards lying on the felt. A chip model is taller than a flat card, so a + * pile drawn over one hides it; the pile is drawn at the nearest spot that leaves the card showing. + */ +public final class FeltClearance { + + /** The face in a card texture: 10 of 16 pixels wide and 14 of 16 tall, centred. */ + static final float FACE_HALF_WIDTH = 5f / 16f; + static final float FACE_HALF_HEIGHT = 7f / 16f; + /** How finely and how far a pile looks for room beside the cards. */ + static final double STEP = 0.02; + static final double REACH = 1.0; + static final int DIRECTIONS = 32; + + private FeltClearance() {} + + /** Where a pile may be drawn, such as on the felt and out of the shoe. */ + @FunctionalInterface + public interface SpotFilter { + boolean allows(double x, double z); + } + + /** A card's face flattened onto the table: world X/Z of its four corners, in order. */ + public record Footprint(double[] xs, double[] zs) {} + + /** The face of a card display at world {@code x}/{@code z} (its translation included) drawn with {@code pose}. */ + public static Footprint footprint(double x, double z, DisplayPose pose) { + float[][] corners = { + {-FACE_HALF_WIDTH, -FACE_HALF_HEIGHT}, + {FACE_HALF_WIDTH, -FACE_HALF_HEIGHT}, + {FACE_HALF_WIDTH, FACE_HALF_HEIGHT}, + {-FACE_HALF_WIDTH, FACE_HALF_HEIGHT} + }; + Vector3f scale = pose.scale(); + double[] xs = new double[4]; + double[] zs = new double[4]; + for (int i = 0; i < 4; i++) { + Vector3f corner = new Vector3f(corners[i][0], corners[i][1], 0f); + pose.rightRotation().transform(corner); + corner.mul(scale); + pose.leftRotation().transform(corner); + xs[i] = x + corner.x; + zs[i] = z + corner.z; + } + return new Footprint(xs, zs); + } + + /** How far {@code x}/{@code z} is from the card, or 0 on top of it. */ + public static double distance(double x, double z, Footprint card) { + double[] xs = card.xs(); + double[] zs = card.zs(); + boolean anyLeft = false; + boolean anyRight = false; + double best = Double.MAX_VALUE; + for (int i = 0; i < 4; i++) { + int j = (i + 1) % 4; + double ex = xs[j] - xs[i]; + double ez = zs[j] - zs[i]; + double cross = ex * (z - zs[i]) - ez * (x - xs[i]); + if (cross > 0) { + anyLeft = true; + } else if (cross < 0) { + anyRight = true; + } + best = Math.min(best, segmentDistance(x, z, xs[i], zs[i], ex, ez)); + } + // Inside is on one side of every edge. A card squashed to a point has no inside. + return anyLeft != anyRight ? 0 : best; + } + + private static double segmentDistance(double x, double z, double ax, double az, double ex, double ez) { + double lengthSq = ex * ex + ez * ez; + double t = lengthSq > 0 ? ((x - ax) * ex + (z - az) * ez) / lengthSq : 0; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(x - (ax + ex * t), z - (az + ez * t)); + } + + /** True when a pile at {@code x}/{@code z} keeps at least {@code gap} from every card. */ + public static boolean clear(double x, double z, List cards, double gap) { + for (Footprint card : cards) { + if (distance(x, z, card) < gap) { + return false; + } + } + return true; + } + + /** + * The closest spot to {@code x}/{@code z} that is clear of the cards and that {@code filter} + * allows, searched out to {@link #REACH}. The spot itself when it is already clear, and also + * when nothing in reach is, so a pile never jumps somewhere arbitrary. + */ + public static double[] nearestClear(double x, double z, List cards, double gap, SpotFilter filter) { + if (cards.isEmpty() || gap <= 0 || clear(x, z, cards, gap)) { + return new double[] {x, z}; + } + int rings = (int) Math.round(REACH / STEP); + for (int ring = 1; ring <= rings; ring++) { + double radius = ring * STEP; + for (int i = 0; i < DIRECTIONS; i++) { + double angle = Math.PI * 2 * i / DIRECTIONS; + double cx = x + Math.cos(angle) * radius; + double cz = z + Math.sin(angle) * radius; + if (clear(cx, cz, cards, gap) && filter.allows(cx, cz)) { + return new double[] {cx, cz}; + } + } + } + return new double[] {x, z}; + } +} diff --git a/src/main/java/net/tfminecraft/games/loader/ConfigLoader.java b/src/main/java/net/tfminecraft/games/loader/ConfigLoader.java index ca65bab..96bcde5 100644 --- a/src/main/java/net/tfminecraft/games/loader/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/games/loader/ConfigLoader.java @@ -113,6 +113,8 @@ public boolean loadSafe(File configFile) { Cache.wagerYOffset = config.getDouble("wager.y-offset", Cache.wagerYOffset); double merge = config.getDouble("wager.merge-range", Cache.wagerMergeRange); Cache.wagerMergeRange = merge > 0 ? merge : Cache.wagerMergeRange; + double cardGap = config.getDouble("wager.card-gap", Cache.wagerCardGap); + Cache.wagerCardGap = cardGap >= 0 ? cardGap : Cache.wagerCardGap; int voteSeconds = config.getInt("wager.vote-seconds", Cache.wagerVoteSeconds); Cache.wagerVoteSeconds = voteSeconds > 0 ? voteSeconds : Cache.wagerVoteSeconds; int placeSeconds = config.getInt("wager.place-seconds", Cache.wagerPlaceSeconds); diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index cd3f7de..790241d 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -82,6 +82,7 @@ import net.tfminecraft.games.game.LiveCardReturns; import net.tfminecraft.games.guild.GuildTables; import net.tfminecraft.games.select.CardSelector; +import net.tfminecraft.games.layout.FeltClearance; import net.tfminecraft.games.layout.HandAnchor; import net.tfminecraft.games.layout.HandLayout; import net.tfminecraft.games.layout.RevealLayout; @@ -1048,6 +1049,7 @@ private void clearDealer(Player player) { void rebuildCardStacks(Table table) { rebuildShoeStack(table); rebuildDiscardStack(table); + stepPilesOffCards(table); } private void rebuildShoeStack(Table table) { @@ -1592,6 +1594,7 @@ public void syncBucketChips(Table table, UUID owner) { } Location anchor = boxLocation(table, owner); boolean tray = owner.equals(table.getId()); + List cards = feltCards(table); int slot = 0; for (Stake stake : table.ledger().stakes(owner)) { ItemStack one = stake.item().clone(); @@ -1615,6 +1618,7 @@ public void syncBucketChips(Table table, UUID owner) { at.getX(), at.getZ()); pile.setPieces(add * per); pile.setStreetId(stake.streetId()); + stepOffCards(table, pile, cards); if (!rebuildPile(table, pile)) { despawnPile(pile); return; @@ -1645,6 +1649,112 @@ private void clearBucketChips(Table table, UUID owner) { } } + /** + * Cards lying on the felt that chips must not cover: the shoe, the discard and the public + * piles. Every layer of a stack has the same outline, so its bottom layer stands for all. + */ + private static List feltCards(Table table) { + List cards = new ArrayList<>(); + if (Cache.wagerCardGap <= 0) { + return cards; + } + DisplayManager displays = DisplayManager.get(); + if (!table.getStackTokens().isEmpty()) { + addFootprint(cards, displays, table.getStackTokens().get(0)); + } + if (!table.getDiscardTokens().isEmpty()) { + addFootprint(cards, displays, table.getDiscardTokens().get(0)); + } + for (List pile : table.tablePiles().values()) { + for (HandCard held : pile) { + addFootprint(cards, displays, held.tokenId()); + } + } + return cards; + } + + private static void addFootprint(List cards, DisplayManager displays, UUID token) { + Location at = displays.worldLocation(token); + DisplayPose pose = displays.poseOf(token); + if (at != null && pose != null) { + cards.add(FeltClearance.footprint(at.getX(), at.getZ(), pose)); + } + } + + /** Draw the pile at the closest spot to its home that leaves every card showing. */ + private static void stepOffCards(Table table, PotPile pile, List cards) { + double[] spot = clearSpot(table, pile.homeX(), pile.homeZ(), cards); + pile.setX(spot[0]); + pile.setZ(spot[1]); + } + + /** + * Clear of the cards, out of the shoe, and on the same kind of ground as home: a tray pile + * stays in the tray, a felt pile stays on the felt and out of the tray. + */ + private static double[] clearSpot(Table table, double x, double z, List cards) { + Location home = table.getOrigin().clone(); + home.setX(x); + home.setZ(z); + boolean tray = inTrayZone(table, home); + boolean felt = !tray && onFelt(table, home); + Location probe = home.clone(); + return FeltClearance.nearestClear(x, z, cards, Cache.wagerCardGap, (cx, cz) -> { + probe.setX(cx); + probe.setZ(cz); + if (inShoeZone(table, probe)) { + return false; + } + boolean inTray = inTrayZone(table, probe); + if (tray || inTray) { + return tray && inTray; + } + return !felt || onFelt(table, probe); + }); + } + + /** Cards came or went: redraw each pile whose clear spot moved, keeping how its chips lie. */ + private void stepPilesOffCards(Table table) { + if (table.getPiles().isEmpty()) { + return; + } + List cards = feltCards(table); + for (PotPile pile : new ArrayList<>(table.getPiles())) { + double[] spot = clearSpot(table, pile.homeX(), pile.homeZ(), cards); + if (Math.abs(spot[0] - pile.x()) < 1e-6 && Math.abs(spot[1] - pile.z()) < 1e-6) { + continue; + } + despawnPile(pile); + pile.setX(spot[0]); + pile.setZ(spot[1]); + if (!rebuildPile(table, pile)) { + despawnPile(pile); + table.getPiles().remove(pile); + } + } + } + + /** + * A click on a pile drawn beside a card adds to that pile, so the spot is the pile's home, + * where its stake lives. Anywhere else the click is the spot, as before. + */ + private static Location spotFor(Table table, UUID bucket, ItemStack one, Location hit) { + double range = Cache.wagerMergeRange; + for (PotPile pile : table.getPiles()) { + boolean moved = pile.x() != pile.homeX() || pile.z() != pile.homeZ(); + if (!moved || !bucket.equals(pile.ownerId()) || !pile.item().isSimilar(one)) { + continue; + } + if (Math.hypot(pile.x() - hit.getX(), pile.z() - hit.getZ()) <= range) { + Location home = hit.clone(); + home.setX(pile.homeX()); + home.setZ(pile.homeZ()); + return home; + } + } + return hit; + } + /** * The spot a stake was put on, or null when nobody chose one and a layout decides instead. * Bounds were checked when it was placed, so this is taken at face value. @@ -1723,8 +1833,9 @@ private boolean traySlotTaken(Table table, Location slot) { if (!isTrayPile(table, pile)) { continue; } - double dx = pile.x() - slot.getX(); - double dz = pile.z() - slot.getZ(); + // A slot is where a pile was laid out, even when a card has it drawn beside the slot. + double dx = pile.homeX() - slot.getX(); + double dz = pile.homeZ() - slot.getZ(); if (dx * dx + dz * dz <= maxSq) { return true; } @@ -2175,6 +2286,7 @@ private DisplayPose pileSlot(Table table, String pile, int index, int count, boo } private void notifyTablePiles(Table table) { + stepPilesOffCards(table); Game game = gameOf(table); if (game != null) { game.onTablePilesChanged(table); @@ -2386,6 +2498,7 @@ private List makeFlights(Table table, List stakes, Location if (!Cache.wagerShowChips) { return flights; } + List cards = feltCards(table); int slot = 0; for (Stake stake : stakes) { ItemStack one = stake.item().clone(); @@ -2406,6 +2519,8 @@ private List makeFlights(Table table, List stakes, Location at.getX(), at.getZ()); pile.setPieces(add * per); pile.setStreetId(stake.streetId()); + // Chips fly off from where the heap is drawn, which may be beside a card. + stepOffCards(table, pile, cards); if (!rebuildPile(table, pile)) { despawnPile(pile); return flights; @@ -2778,7 +2893,7 @@ private boolean dumpLoot(Player player, Table table, ItemStack snapshot, int den // a wager that cannot be placed never eats the loot. TxResult staked = wager().begin(table, dealerTray ? "dealer tray loot" : "loot wager") .move(Accounts.declared(table, player, one, denars), - Accounts.bucket(table, bucket).placedAt(at), placeDenars) + Accounts.bucket(table, bucket).placedAt(spotFor(table, bucket, one, at)), placeDenars) .commit(); // A refused transaction moves nothing, so the amount alone says whether it happened. if (staked.moved() < 1) { @@ -2919,7 +3034,7 @@ private boolean tryPlaceChip(Player player, Location click) { // the player's hand and worth exactly this much, so the movement always goes through. wager().begin(table, dealerTray ? "dealer tray" : "bet") .move(Accounts.pockets(table, player, one::isSimilar), - Accounts.bucket(table, bucket).placedAt(hit), denars) + Accounts.bucket(table, bucket).placedAt(spotFor(table, bucket, one, hit)), denars) .commit(); playChipSound(table, hit); if (!dealerTray) { @@ -3182,7 +3297,7 @@ private int countBlackjackBoxes(Table table) { return boxOwners(table).size(); } - /** Draw a freshly laid out pile. Both callers build a new pile, so there is nothing to clear. */ + /** Draw a laid out pile. Every caller hands over a pile with no displays, so there is nothing to clear. */ private boolean rebuildPile(Table table, PotPile pile) { DisplayManager displays = DisplayManager.get(); ItemStack visual = pileDisplayItem(pile); diff --git a/src/main/java/net/tfminecraft/games/wager/PotPile.java b/src/main/java/net/tfminecraft/games/wager/PotPile.java index 5766e38..3ca8108 100644 --- a/src/main/java/net/tfminecraft/games/wager/PotPile.java +++ b/src/main/java/net/tfminecraft/games/wager/PotPile.java @@ -20,6 +20,8 @@ public final class PotPile { private int streetId; private double x; private double z; + private final double homeX; + private final double homeZ; private final List tokens = new ArrayList<>(); private final List layerYaws = new ArrayList<>(); @@ -30,6 +32,8 @@ public PotPile(UUID ownerId, ItemStack item, String typeKey, int denars, double this.denars = denars; this.x = x; this.z = z; + this.homeX = x; + this.homeZ = z; } /** Which bucket this pile draws. The table id means the house tray. */ @@ -89,6 +93,15 @@ public void setZ(double z) { this.z = z; } + /** Where the layout put this pile. It is drawn at {@link #x()}/{@link #z()}, which steps off any card there. */ + public double homeX() { + return homeX; + } + + public double homeZ() { + return homeZ; + } + public List tokens() { return tokens; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 004fe26..8b39a2f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -95,6 +95,9 @@ wager: random-yaw: false # How close a click must be to add onto an existing pile of the same type. merge-range: 0.1 + # Chips are drawn at least this far (blocks, from the pile centre) from the deck, the discard + # and the cards dealt to the table, so they never cover them. 0 lets chips sit on cards. + card-gap: 0.1 vote-seconds: 30 # After loot is accepted, seconds to click the felt with the same item. place-seconds: 10 diff --git a/src/test/java/net/tfminecraft/games/layout/FeltClearanceTest.java b/src/test/java/net/tfminecraft/games/layout/FeltClearanceTest.java new file mode 100644 index 0000000..45b644e --- /dev/null +++ b/src/test/java/net/tfminecraft/games/layout/FeltClearanceTest.java @@ -0,0 +1,105 @@ +package net.tfminecraft.games.layout; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import net.tfminecraft.games.cache.Cache; +import net.tfminecraft.games.display.DisplayPose; +import net.tfminecraft.games.layout.FeltClearance.Footprint; + +class FeltClearanceTest { + private static final float SCALE = 0.35f; + private static final double HALF_WIDTH = SCALE * 5 / 16.0; + private static final double HALF_HEIGHT = SCALE * 7 / 16.0; + private float[] previous; + + @BeforeEach + void flatCards() { + previous = new float[] {Cache.tableCardYawOffset, Cache.tableCardPitch, Cache.tableCardRoll}; + Cache.tableCardYawOffset = 0; + Cache.tableCardPitch = -90; + Cache.tableCardRoll = 0; + } + + @AfterEach + void restore() { + Cache.tableCardYawOffset = previous[0]; + Cache.tableCardPitch = previous[1]; + Cache.tableCardRoll = previous[2]; + } + + private static Footprint card(double x, double z, float yaw) { + return FeltClearance.footprint(x, z, DisplayPose.flatOnTable(SCALE, 0f, yaw)); + } + + private static void assertSpan(double[] values, double min, double max) { + assertEquals(min, Arrays.stream(values).min().orElseThrow(), 1e-5); + assertEquals(max, Arrays.stream(values).max().orElseThrow(), 1e-5); + } + + @Test + void aFlatCardCoversItsFaceAndTurnsWithTheTable() { + Footprint ahead = card(2, 3, 0); + assertSpan(ahead.xs(), 2 - HALF_WIDTH, 2 + HALF_WIDTH); + assertSpan(ahead.zs(), 3 - HALF_HEIGHT, 3 + HALF_HEIGHT); + Footprint turned = card(0, 0, 90); + assertSpan(turned.xs(), -HALF_HEIGHT, HALF_HEIGHT); + assertSpan(turned.zs(), -HALF_WIDTH, HALF_WIDTH); + } + + @Test + void distanceIsZeroOnTheCardAndMeasuredToTheNearestEdgeOrCorner() { + Footprint card = card(0, 0, 0); + assertEquals(0, FeltClearance.distance(0, 0, card)); + assertEquals(0, FeltClearance.distance(HALF_WIDTH * 0.9, -HALF_HEIGHT * 0.9, card)); + assertEquals(0.3 - HALF_WIDTH, FeltClearance.distance(0.3, 0, card), 1e-5); + assertEquals(0.4 - HALF_HEIGHT, FeltClearance.distance(0, -0.4, card), 1e-5); + assertEquals(Math.hypot(0.3 - HALF_WIDTH, 0.4 - HALF_HEIGHT), FeltClearance.distance(0.3, 0.4, card), 1e-5); + } + + @Test + void aCardSquashedToAPointCoversNothingButThatPoint() { + Footprint dot = FeltClearance.footprint(1, 1, DisplayPose.flatOnTable(0f, 0f, 0)); + assertEquals(0.5, FeltClearance.distance(1.5, 1, dot), 1e-6); + assertEquals(0, FeltClearance.distance(1, 1, dot), 1e-6); + } + + @Test + void clearNeedsTheGapFromEveryCard() { + List cards = List.of(card(0, 0, 0), card(1, 0, 0)); + double between = 0.5; + assertTrue(FeltClearance.clear(between, 0, cards, 0.1)); + assertFalse(FeltClearance.clear(between, 0, cards, 0.5 - HALF_WIDTH + 0.01)); + assertFalse(FeltClearance.clear(1, 0, cards, 0.1)); + } + + @Test + void aPileOnACardStepsJustPastItsEdge() { + List cards = List.of(card(0, 0, 0)); + double[] spot = FeltClearance.nearestClear(0, 0, cards, 0.1, (x, z) -> true); + assertTrue(FeltClearance.clear(spot[0], spot[1], cards, 0.1)); + assertTrue(Math.hypot(spot[0], spot[1]) <= HALF_WIDTH + 0.1 + FeltClearance.STEP + 1e-9, + "The pile moves no further than it has to: " + Arrays.toString(spot)); + } + + @Test + void theFilterDecidesWhichSideThePileStepsTo() { + List cards = List.of(card(0, 0, 0)); + double[] spot = FeltClearance.nearestClear(0, 0, cards, 0.1, (x, z) -> x < 0); + assertTrue(spot[0] < -HALF_WIDTH); + assertTrue(FeltClearance.clear(spot[0], spot[1], cards, 0.1)); + } + + @Test + void aPileStaysWhereItIsWhenItIsClearOrHasNowhereToGo() { + List cards = List.of(card(0, 0, 0)); + assertArrayEquals(new double[] {0.5, 0}, FeltClearance.nearestClear(0.5, 0, cards, 0.1, (x, z) -> false)); + assertArrayEquals(new double[] {0, 0}, FeltClearance.nearestClear(0, 0, cards, 0.1, (x, z) -> false)); + assertArrayEquals(new double[] {0, 0}, FeltClearance.nearestClear(0, 0, cards, 0, (x, z) -> true)); + assertArrayEquals(new double[] {0, 0}, FeltClearance.nearestClear(0, 0, List.of(), 0.1, (x, z) -> true)); + } +} diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java new file mode 100644 index 0000000..53a22ce --- /dev/null +++ b/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java @@ -0,0 +1,205 @@ +package net.tfminecraft.games.table; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Player; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.util.RayTraceResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import net.tfminecraft.games.cache.Cache; +import net.tfminecraft.games.display.DisplayPose; +import net.tfminecraft.games.layout.FeltClearance; +import net.tfminecraft.games.layout.TableLayout; +import net.tfminecraft.games.wager.PotPile; +import net.tfminecraft.games.wager.Stake; +import net.tfminecraft.games.wager.WagerEngine; +import net.tfminecraft.games.wager.WagerItemOverride; + +/** Chips are drawn beside the cards on the felt, never over them. */ +class TableManagerCardGapTest extends TableManagerFixture { + private static final double GAP = 0.1; + private final Map origins = new HashMap<>(); + private final Map poses = new HashMap<>(); + private Map previousLayouts; + private double previousGap; + + @BeforeEach void trackDisplays() { + previousLayouts = new HashMap<>(Cache.tableLayouts); + previousGap = Cache.wagerCardGap; + Cache.wagerCardGap = GAP; + Cache.tableLayouts.put("freeplay", new TableLayout(Cache.pokerCardSet, "Cards", "icon", 6, + Map.of("board", new TableLayout.PileSlot(0.55, 0)), + new TableLayout.FeltRing(0.3, 1.2), null, null, 0)); + // Remember where every display is drawn, as the packet renderer does. + doAnswer(call -> { + origins.put(call.getArgument(0), ((Location) call.getArgument(1)).clone()); + poses.put(call.getArgument(0), call.getArgument(3)); + return true; + }).when(display).spawn(any(), any(), any(), any()); + doAnswer(call -> { + origins.remove(call.getArgument(0)); + poses.remove(call.getArgument(0)); + return null; + }).when(display).despawn(any()); + doAnswer(call -> { + if (origins.containsKey(call.getArgument(0))) { + poses.put(call.getArgument(0), call.getArgument(1)); + } + return null; + }).when(display).setTransform(any(), any(), anyInt()); + when(display.worldLocation(any())).thenAnswer(call -> { + Location at = origins.get(call.getArgument(0)); + if (at == null) return null; + var t = poses.get(call.getArgument(0)).translation(); + return at.clone().add(t.x, t.y, t.z); + }); + when(display.poseOf(any())).thenAnswer(call -> poses.get(call.getArgument(0))); + } + + @AfterEach void restoreLayouts() { + Cache.tableLayouts.clear(); + Cache.tableLayouts.putAll(previousLayouts); + Cache.wagerCardGap = previousGap; + } + + private Location boardCard(Table table) { + manager.dealToTable(table, "board", 1, true); + UUID token = table.tablePile("board").getFirst().tokenId(); + return display.worldLocation(token); + } + + private FeltClearance.Footprint footprint(Table table) { + UUID token = table.tablePile("board").getFirst().tokenId(); + Location at = display.worldLocation(token); + return FeltClearance.footprint(at.getX(), at.getZ(), display.poseOf(token)); + } + + private void stakeAt(PlayerMock actor, Location hit) { + actor.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET)); + Player clicker = mock(Player.class, org.mockito.AdditionalAnswers.delegatesTo(actor)); + doReturn(new RayTraceResult(hit.toVector())).when(clicker).rayTraceBlocks(anyDouble()); + PlayerInteractEvent click = new PlayerInteractEvent(clicker, Action.RIGHT_CLICK_BLOCK, + actor.getInventory().getItemInMainHand(), world.getBlockAt(0, 64, 0), + BlockFace.UP, EquipmentSlot.HAND); + manager.onInteractBlock(click); + assertTrue(click.isCancelled()); + } + + private PotPile onlyPile(Table table) { + List piles = new ArrayList<>(table.getPiles()); + assertEquals(1, piles.size()); + return piles.getFirst(); + } + + /** Display translations are floats, so a card's spot is only that close to the click. */ + private static void assertAt(Location expected, double x, double z) { + assertEquals(expected.getX(), x, 1e-6); + assertEquals(expected.getZ(), z, 1e-6); + } + + @Test void coinsPutOnABoardCardAreDrawnBesideItAndGoBackOnceTheCardIsGone() { + Table table = place(false); + Location card = boardCard(table); + stakeAt(player, card); + PotPile pile = onlyPile(table); + assertAt(card, pile.homeX(), pile.homeZ()); + Stake stake = table.ledger().stakes(player.getUniqueId()).getFirst(); + assertAt(card, stake.x(), stake.z()); + assertTrue(FeltClearance.clear(pile.x(), pile.z(), List.of(footprint(table)), GAP), + "The coins leave the card showing"); + Location drawn = display.worldLocation(pile.tokens().getFirst()); + assertEquals(pile.x(), drawn.getX(), 1e-9); + assertEquals(pile.z(), drawn.getZ(), 1e-9); + Location probe = card.clone(); + probe.setX(pile.x()); + probe.setZ(pile.z()); + assertTrue(Cache.layoutOf("freeplay").onFelt(table, probe), "The coins stay on the felt"); + + manager.muckTable(table, "board"); + PotPile back = onlyPile(table); + assertAt(card, back.x(), back.z()); + } + + @Test void aCardDealtOntoCoinsPushesThemAsideWithoutRedrawingTheirChips() { + Table table = place(false); + Location spot = table.getOrigin().clone().add(-0.55, 0, 0); + stakeAt(player, spot); + PotPile pile = onlyPile(table); + assertAt(spot, pile.x(), pile.z()); + List yaws = List.copyOf(pile.layerYaws()); + + Location card = boardCard(table); + assertAt(spot, card.getX(), card.getZ()); + assertSame(pile, onlyPile(table)); + assertTrue(FeltClearance.clear(pile.x(), pile.z(), List.of(footprint(table)), GAP)); + assertEquals(yaws, pile.layerYaws(), "The chips keep how they lie"); + assertEquals(1, pile.tokens().size()); + assertNotNull(display.worldLocation(pile.tokens().getFirst())); + } + + @Test void clickingCoinsDrawnBesideACardAddsToThatSameHeap() { + Table table = place(false); + Location card = boardCard(table); + stakeAt(player, card); + PotPile pile = onlyPile(table); + Location visible = card.clone(); + visible.setX(pile.x()); + visible.setZ(pile.z()); + + stakeAt(player, visible); + List stakes = table.ledger().stakes(player.getUniqueId()); + assertEquals(1, stakes.size()); + assertEquals(2, stakes.getFirst().count()); + assertAt(card, stakes.getFirst().x(), stakes.getFirst().z()); + PotPile merged = onlyPile(table); + assertEquals(2, merged.pieces()); + assertEquals(pile.x(), merged.x(), 1e-9); + assertEquals(pile.z(), merged.z(), 1e-9); + } + + @Test void trayCoinsDrawnBesideACardStillHoldTheirTraySlot() { + Cache.tableLayouts.put("freeplay", new TableLayout(Cache.pokerCardSet, "Cards", "icon", 6, + Map.of("board", new TableLayout.PileSlot(0.55, 0), "tray", new TableLayout.PileSlot(0.55, 0)), + new TableLayout.FeltRing(0.3, 1.2), null, null, 0.4)); + Cache.wagerSilver = new WagerItemOverride("IRON_NUGGET", 5, null, null, null, null, + null, null, false, null); + Table table = place(false); + boardCard(table); + WagerEngine.get().restore(table, table.getId(), new ItemStack(Material.GOLD_NUGGET), "GOLD_NUGGET", 1, + 1, 2, null, null); + WagerEngine.get().restore(table, table.getId(), new ItemStack(Material.IRON_NUGGET), "IRON_NUGGET", 5, + 1, 2, null, null); + manager.syncAllChips(table); + List tray = new ArrayList<>(table.getPiles()); + assertEquals(2, tray.size()); + assertNotEquals(List.of(tray.get(0).homeX(), tray.get(0).homeZ()), + List.of(tray.get(1).homeX(), tray.get(1).homeZ()), "Each kind keeps its own slot"); + for (PotPile pile : tray) { + assertTrue(FeltClearance.clear(pile.x(), pile.z(), List.of(footprint(table)), GAP)); + } + } + + @Test void aZeroGapLetsCoinsSitOnCardsAsBefore() { + Cache.wagerCardGap = 0; + Table table = place(false); + Location card = boardCard(table); + stakeAt(player, card); + PotPile pile = onlyPile(table); + assertAt(card, pile.x(), pile.z()); + } +} From 601ed5ed68fbefafcd454144c99b06b0955a841d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:15:35 +0200 Subject: [PATCH 2/5] Keep heaps pushed off a card apart and merge clicks into their stake Piles are laid out first and drawn after: piles whose home is already clear of the cards stay put, and the rest step off the cards a spread step away from every pile already placed, so overflow heaps pushed off one card no longer land on the same spot. Flights only keep apart from each other, since the heaps they replace are still drawn. A pile now remembers its stake's spot, so clicking an overflow heap drawn beside a card adds to that stake instead of starting a new one. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/games/table/TableManager.java | 117 +++++++++++++----- .../net/tfminecraft/games/wager/PotPile.java | 16 +++ .../games/table/TableManagerCardGapTest.java | 47 +++++++ 3 files changed, 147 insertions(+), 33 deletions(-) diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index 790241d..6866670 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -1594,7 +1594,8 @@ public void syncBucketChips(Table table, UUID owner) { } Location anchor = boxLocation(table, owner); boolean tray = owner.equals(table.getId()); - List cards = feltCards(table); + List others = new ArrayList<>(table.getPiles()); + List fresh = new ArrayList<>(); int slot = 0; for (Stake stake : table.ledger().stakes(owner)) { ItemStack one = stake.item().clone(); @@ -1618,15 +1619,24 @@ public void syncBucketChips(Table table, UUID owner) { at.getX(), at.getZ()); pile.setPieces(add * per); pile.setStreetId(stake.streetId()); - stepOffCards(table, pile, cards); - if (!rebuildPile(table, pile)) { - despawnPile(pile); - return; + if (spot != null) { + pile.setStakeSpot(spot.getX(), spot.getZ()); } + // Listed before it is drawn, so the next tray slot sees this one taken. table.getPiles().add(pile); + fresh.add(pile); left -= add; } } + placeOffCards(table, fresh, others, feltCards(table)); + for (int i = 0; i < fresh.size(); i++) { + PotPile pile = fresh.get(i); + if (!rebuildPile(table, pile)) { + despawnPile(pile); + table.getPiles().removeAll(fresh.subList(i, fresh.size())); + return; + } + } } /** Redraw every bucket, for load, chunk load, and the show-chips toggle. */ @@ -1681,25 +1691,53 @@ private static void addFootprint(List cards, DisplayMan } } - /** Draw the pile at the closest spot to its home that leaves every card showing. */ - private static void stepOffCards(Table table, PotPile pile, List cards) { - double[] spot = clearSpot(table, pile.homeX(), pile.homeZ(), cards); - pile.setX(spot[0]); - pile.setZ(spot[1]); + /** + * Where each fresh pile is drawn. A pile whose home leaves every card showing stays there. + * The rest go to the closest spot that does, a spread step from {@code fixed} and from every + * pile already placed, so two heaps pushed off the same card never land on one spot. + */ + private static void placeOffCards(Table table, List fresh, List fixed, + List cards) { + List placed = new ArrayList<>(fixed); + List blocked = new ArrayList<>(); + for (PotPile pile : fresh) { + pile.setX(pile.homeX()); + pile.setZ(pile.homeZ()); + if (FeltClearance.clear(pile.homeX(), pile.homeZ(), cards, Cache.wagerCardGap)) { + placed.add(pile); + } else { + blocked.add(pile); + } + } + for (PotPile pile : blocked) { + double[] spot = clearSpot(table, pile, cards, placed); + pile.setX(spot[0]); + pile.setZ(spot[1]); + placed.add(pile); + } } /** - * Clear of the cards, out of the shoe, and on the same kind of ground as home: a tray pile - * stays in the tray, a felt pile stays on the felt and out of the tray. + * Clear of the cards and of the other piles, out of the shoe, and on the same kind of ground + * as home: a tray pile stays in the tray, a felt pile stays on the felt and out of the tray. */ - private static double[] clearSpot(Table table, double x, double z, List cards) { + private static double[] clearSpot(Table table, PotPile pile, List cards, + List others) { + double x = pile.homeX(); + double z = pile.homeZ(); Location home = table.getOrigin().clone(); home.setX(x); home.setZ(z); boolean tray = inTrayZone(table, home); boolean felt = !tray && onFelt(table, home); Location probe = home.clone(); + double spacing = pileSpacing(); return FeltClearance.nearestClear(x, z, cards, Cache.wagerCardGap, (cx, cz) -> { + for (PotPile other : others) { + if (other != pile && Math.hypot(other.x() - cx, other.z() - cz) < spacing) { + return false; + } + } probe.setX(cx); probe.setZ(cz); if (inShoeZone(table, probe)) { @@ -1713,20 +1751,28 @@ private static double[] clearSpot(Table table, double x, double z, List cards = feltCards(table); - for (PotPile pile : new ArrayList<>(table.getPiles())) { - double[] spot = clearSpot(table, pile.homeX(), pile.homeZ(), cards); - if (Math.abs(spot[0] - pile.x()) < 1e-6 && Math.abs(spot[1] - pile.z()) < 1e-6) { + List piles = new ArrayList<>(table.getPiles()); + Map was = new IdentityHashMap<>(); + for (PotPile pile : piles) { + was.put(pile, new double[] {pile.x(), pile.z()}); + } + placeOffCards(table, piles, List.of(), feltCards(table)); + for (PotPile pile : piles) { + double[] old = was.get(pile); + if (Math.abs(old[0] - pile.x()) < 1e-6 && Math.abs(old[1] - pile.z()) < 1e-6) { continue; } despawnPile(pile); - pile.setX(spot[0]); - pile.setZ(spot[1]); if (!rebuildPile(table, pile)) { despawnPile(pile); table.getPiles().remove(pile); @@ -1735,8 +1781,9 @@ private void stepPilesOffCards(Table table) { } /** - * A click on a pile drawn beside a card adds to that pile, so the spot is the pile's home, - * where its stake lives. Anywhere else the click is the spot, as before. + * A click on a pile drawn beside a card adds to that pile, so the spot is where its stake + * lives (the pile's home when the layout placed that stake). Anywhere else the click is the + * spot, as before. */ private static Location spotFor(Table table, UUID bucket, ItemStack one, Location hit) { double range = Cache.wagerMergeRange; @@ -1747,8 +1794,8 @@ private static Location spotFor(Table table, UUID bucket, ItemStack one, Locatio } if (Math.hypot(pile.x() - hit.getX(), pile.z() - hit.getZ()) <= range) { Location home = hit.clone(); - home.setX(pile.homeX()); - home.setZ(pile.homeZ()); + home.setX(pile.stakeX() != null ? pile.stakeX() : pile.homeX()); + home.setZ(pile.stakeZ() != null ? pile.stakeZ() : pile.homeZ()); return home; } } @@ -1774,7 +1821,7 @@ private Location spreadSlot(Table table, Location anchor, int index) { if (index <= 0) { return anchor.clone(); } - double step = Math.max(0.12, Cache.wagerMergeRange); + double step = pileSpacing(); int[] walk = spiralStep(index); double forward = TableLayout.localForward(table, anchor) + walk[1] * step; double right = TableLayout.localRight(table, anchor) + walk[0] * step; @@ -1821,7 +1868,7 @@ private static Location trayGridAt(Table table, TableLayout layout, Location tra return tray.clone(); } TableLayout.PileSlot origin = layout.pile("tray"); - double step = Math.max(0.12, Cache.wagerMergeRange); + double step = pileSpacing(); int[] walk = spiralStep(index); return TableLayout.fromLocal(table, origin.forward() + walk[1] * step, origin.right() + walk[0] * step); } @@ -2498,7 +2545,7 @@ private List makeFlights(Table table, List stakes, Location if (!Cache.wagerShowChips) { return flights; } - List cards = feltCards(table); + List fresh = new ArrayList<>(); int slot = 0; for (Stake stake : stakes) { ItemStack one = stake.item().clone(); @@ -2519,16 +2566,20 @@ private List makeFlights(Table table, List stakes, Location at.getX(), at.getZ()); pile.setPieces(add * per); pile.setStreetId(stake.streetId()); - // Chips fly off from where the heap is drawn, which may be beside a card. - stepOffCards(table, pile, cards); - if (!rebuildPile(table, pile)) { - despawnPile(pile); - return flights; - } - flights.add(toTray ? PayoutFlight.toTray(pile) : new PayoutFlight(pile, destId)); + fresh.add(pile); left -= add; } } + // Chips fly off from where the heap is drawn, which may be beside a card. The heaps + // paying out are still drawn, so flights only keep apart from each other. + placeOffCards(table, fresh, List.of(), feltCards(table)); + for (PotPile pile : fresh) { + if (!rebuildPile(table, pile)) { + despawnPile(pile); + return flights; + } + flights.add(toTray ? PayoutFlight.toTray(pile) : new PayoutFlight(pile, destId)); + } return flights; } diff --git a/src/main/java/net/tfminecraft/games/wager/PotPile.java b/src/main/java/net/tfminecraft/games/wager/PotPile.java index 3ca8108..3b42dd7 100644 --- a/src/main/java/net/tfminecraft/games/wager/PotPile.java +++ b/src/main/java/net/tfminecraft/games/wager/PotPile.java @@ -22,6 +22,8 @@ public final class PotPile { private double z; private final double homeX; private final double homeZ; + private Double stakeX; + private Double stakeZ; private final List tokens = new ArrayList<>(); private final List layerYaws = new ArrayList<>(); @@ -102,6 +104,20 @@ public double homeZ() { return homeZ; } + /** The spot of the stake this pile draws, or null when the layout placed that stake. */ + public Double stakeX() { + return stakeX; + } + + public Double stakeZ() { + return stakeZ; + } + + public void setStakeSpot(double x, double z) { + stakeX = x; + stakeZ = z; + } + public List tokens() { return tokens; } diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java index 53a22ce..9f4d81e 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java @@ -194,6 +194,53 @@ private static void assertAt(Location expected, double x, double z) { } } + /** A stake too tall for one stack, put on the board card: it spills into several heaps. */ + private List spilledOntoCard(Table table, Location card) { + Cache.wagerGold = new WagerItemOverride("GOLD_NUGGET", 1, null, 3, null, null, false, null, false, null); + WagerEngine.get().restore(table, player.getUniqueId(), new ItemStack(Material.GOLD_NUGGET), "gold", 1, + 120, table.street(), null, null, card.getX(), card.getZ()); + manager.syncAllChips(table); + List piles = new ArrayList<>(table.getPiles()); + assertTrue(piles.size() >= 3, "The stake spills: " + piles.size()); + return piles; + } + + @Test void heapsSpilledOntoACardStepOffItWithoutLandingOnEachOther() { + Table table = place(false); + Location card = boardCard(table); + List piles = spilledOntoCard(table, card); + for (int i = 0; i < piles.size(); i++) { + PotPile pile = piles.get(i); + assertTrue(FeltClearance.clear(pile.x(), pile.z(), List.of(footprint(table)), GAP)); + for (int j = i + 1; j < piles.size(); j++) { + PotPile other = piles.get(j); + assertTrue(Math.hypot(pile.x() - other.x(), pile.z() - other.z()) >= 0.12 - 1e-9, + "Heaps " + i + " and " + j + " are drawn apart"); + } + } + // Cards changing again keeps them apart and where they were. + List> before = piles.stream().map(p -> List.of(p.x(), p.z())).toList(); + manager.rebuildCardStacks(table); + assertEquals(before, table.getPiles().stream().map(p -> List.of(p.x(), p.z())).toList()); + } + + @Test void clickingASpilledHeapBesideACardAddsToItsStake() { + Table table = place(false); + Location card = boardCard(table); + PotPile spill = spilledOntoCard(table, card).stream() + .filter(p -> Math.hypot(p.homeX() - card.getX(), p.homeZ() - card.getZ()) > Cache.wagerMergeRange) + .findFirst().orElseThrow(); + assertNotEquals(spill.homeX(), spill.x(), 1e-9); + Location visible = card.clone(); + visible.setX(spill.x()); + visible.setZ(spill.z()); + + stakeAt(player, visible); + List stakes = table.ledger().stakes(player.getUniqueId()); + assertEquals(1, stakes.size()); + assertEquals(121, stakes.getFirst().count()); + } + @Test void aZeroGapLetsCoinsSitOnCardsAsBefore() { Cache.wagerCardGap = 0; Table table = place(false); From d5eee8f12400a82d1f172e804a2b3bcf7ee5199d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:22:01 +0200 Subject: [PATCH 3/5] Re-place every heap on bucket changes and fly payouts from drawn heaps A bucket redraw now places every pile on the table again, so a heap at its own clear home can stand where another bucket's heap was pushed off a card, and that heap steps aside instead of being covered. Payout flights are told which bucket pays them and start exactly where that bucket's heaps are drawn. Only flights with no drawn heap of their own are placed fresh, around the matched ones, so two owners' flights off one card no longer start on the same spot. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/games/table/TableManager.java | 67 ++++++++++++------- .../net/tfminecraft/games/wager/MoneyTx.java | 4 +- .../tfminecraft/games/wager/WagerHost.java | 7 +- .../games/table/TableManagerCardGapTest.java | 45 +++++++++++++ .../tfminecraft/games/wager/MoneyTxTest.java | 4 +- 5 files changed, 98 insertions(+), 29 deletions(-) diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index 6866670..1bdc551 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -177,9 +177,9 @@ public void moneyMoved(Table table, Collection buckets) { } @Override - public List flights(Table table, List stakes, Location from, UUID destId, + public List flights(Table table, List stakes, Location from, UUID sourceOwner, UUID destId, boolean toTray) { - return makeFlights(table, stakes, from, destId, toTray); + return makeFlights(table, stakes, from, sourceOwner, destId, toTray); } public void startClock() { @@ -1594,8 +1594,6 @@ public void syncBucketChips(Table table, UUID owner) { } Location anchor = boxLocation(table, owner); boolean tray = owner.equals(table.getId()); - List others = new ArrayList<>(table.getPiles()); - List fresh = new ArrayList<>(); int slot = 0; for (Stake stake : table.ledger().stakes(owner)) { ItemStack one = stake.item().clone(); @@ -1624,19 +1622,12 @@ public void syncBucketChips(Table table, UUID owner) { } // Listed before it is drawn, so the next tray slot sees this one taken. table.getPiles().add(pile); - fresh.add(pile); left -= add; } } - placeOffCards(table, fresh, others, feltCards(table)); - for (int i = 0; i < fresh.size(); i++) { - PotPile pile = fresh.get(i); - if (!rebuildPile(table, pile)) { - despawnPile(pile); - table.getPiles().removeAll(fresh.subList(i, fresh.size())); - return; - } - } + // Every pile is placed again: a fresh heap at its own clear home can stand where another + // bucket's heap was pushed off a card, and that heap steps aside. + stepPilesOffCards(table); } /** Redraw every bucket, for load, chunk load, and the show-chips toggle. */ @@ -1751,12 +1742,31 @@ private static double[] clearSpot(Table table, PotPile pile, List used) { + if (sourceOwner == null) { + return null; + } + for (PotPile pile : table.getPiles()) { + if (sourceOwner.equals(pile.ownerId()) && !used.contains(pile) + && Math.abs(pile.homeX() - flight.homeX()) < 1e-9 && Math.abs(pile.homeZ() - flight.homeZ()) < 1e-9 + && pile.item().isSimilar(flight.item())) { + used.add(pile); + return pile; + } + } + return null; + } + /** How far apart neighbouring heaps are laid out, the same step a spilling stake uses. */ private static double pileSpacing() { return Math.max(0.12, Cache.wagerMergeRange); } - /** Cards came or went: redraw each pile whose clear spot moved, keeping how its chips lie. */ + /** + * Place every pile again and draw the ones that moved or were never drawn. A pile that only + * moved keeps how its chips lie. Runs when cards come or go and when a bucket is laid out. + */ private void stepPilesOffCards(Table table) { if (table.getPiles().isEmpty()) { return; @@ -1769,7 +1779,8 @@ private void stepPilesOffCards(Table table) { placeOffCards(table, piles, List.of(), feltCards(table)); for (PotPile pile : piles) { double[] old = was.get(pile); - if (Math.abs(old[0] - pile.x()) < 1e-6 && Math.abs(old[1] - pile.z()) < 1e-6) { + if (!pile.tokens().isEmpty() + && Math.abs(old[0] - pile.x()) < 1e-6 && Math.abs(old[1] - pile.z()) < 1e-6) { continue; } despawnPile(pile); @@ -2539,13 +2550,16 @@ public void checkFeltEmpty(Table table, String stage) { * so the flight can throw them away when it lands. Every stake handed over here holds at * least one real coin, since withdrawals never yield empty parts. */ - private List makeFlights(Table table, List stakes, Location from, UUID destId, - boolean toTray) { + private List makeFlights(Table table, List stakes, Location from, UUID sourceOwner, + UUID destId, boolean toTray) { List flights = new ArrayList<>(); if (!Cache.wagerShowChips) { return flights; } + List flying = new ArrayList<>(); List fresh = new ArrayList<>(); + List matched = new ArrayList<>(); + Set used = Collections.newSetFromMap(new IdentityHashMap<>()); int slot = 0; for (Stake stake : stakes) { ItemStack one = stake.item().clone(); @@ -2566,14 +2580,21 @@ private List makeFlights(Table table, List stakes, Location at.getX(), at.getZ()); pile.setPieces(add * per); pile.setStreetId(stake.streetId()); - fresh.add(pile); + // Chips fly off from where the heap paying them is drawn, which may be beside a card. + PotPile source = drawnSource(table, sourceOwner, pile, used); + if (source != null) { + pile.setX(source.x()); + pile.setZ(source.z()); + matched.add(pile); + } else { + fresh.add(pile); + } + flying.add(pile); left -= add; } } - // Chips fly off from where the heap is drawn, which may be beside a card. The heaps - // paying out are still drawn, so flights only keep apart from each other. - placeOffCards(table, fresh, List.of(), feltCards(table)); - for (PotPile pile : fresh) { + placeOffCards(table, fresh, matched, feltCards(table)); + for (PotPile pile : flying) { if (!rebuildPile(table, pile)) { despawnPile(pile); return flights; diff --git a/src/main/java/net/tfminecraft/games/wager/MoneyTx.java b/src/main/java/net/tfminecraft/games/wager/MoneyTx.java index ef09fd9..3cbb7e7 100644 --- a/src/main/java/net/tfminecraft/games/wager/MoneyTx.java +++ b/src/main/java/net/tfminecraft/games/wager/MoneyTx.java @@ -391,8 +391,8 @@ private TxResult apply(List planned) { table.wagerCharacters().putIfAbsent(player.flightTarget(), characters.get(player.flightTarget())); table.roundMoney().recordLeg(leg.from, leg.to, coins); if (flights != null && from != null) { - flights.addAll(host.flights(table, coins, from, leg.to.flightTarget(), - leg.to.isTray())); + flights.addAll(host.flights(table, coins, from, leg.from.feltOwner(), + leg.to.flightTarget(), leg.to.isTray())); } if (leg.from.onFelt()) { touched.add(leg.from.feltOwner()); diff --git a/src/main/java/net/tfminecraft/games/wager/WagerHost.java b/src/main/java/net/tfminecraft/games/wager/WagerHost.java index a070a4d..4895bdb 100644 --- a/src/main/java/net/tfminecraft/games/wager/WagerHost.java +++ b/src/main/java/net/tfminecraft/games/wager/WagerHost.java @@ -25,7 +25,10 @@ public interface WagerHost { /** Redraw the buckets that changed, save the table, and refresh its label. */ void moneyMoved(Table table, Collection buckets); - /** Throwaway chips for an animation. They hold no value and are dropped when they land. */ - List flights(Table table, List stakes, Location from, UUID destId, + /** + * Throwaway chips for an animation. They hold no value and are dropped when they land. + * {@code sourceOwner} is the bucket they leave, so they can start where its heaps are drawn. + */ + List flights(Table table, List stakes, Location from, UUID sourceOwner, UUID destId, boolean toTray); } diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java index 9f4d81e..789d95b 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java @@ -241,6 +241,51 @@ private List spilledOntoCard(Table table, Location card) { assertEquals(121, stakes.getFirst().count()); } + private PotPile pileOf(Table table, UUID owner) { + List piles = table.getPiles().stream().filter(p -> owner.equals(p.ownerId())).toList(); + assertEquals(1, piles.size()); + return piles.getFirst(); + } + + @Test void aHeapPushedOffACardStepsAsideForAnotherBucketsHeapAtItsOwnHome() { + Table table = place(false); + Location card = boardCard(table); + stakeAt(player, card); + PotPile pushed = pileOf(table, player.getUniqueId()); + Location drawn = card.clone(); + drawn.setX(pushed.x()); + drawn.setZ(pushed.z()); + + PlayerMock other = opponent(); + stakeAt(other, drawn); + PotPile theirs = pileOf(table, other.getUniqueId()); + assertAt(drawn, theirs.x(), theirs.z()); + PotPile mine = pileOf(table, player.getUniqueId()); + assertTrue(Math.hypot(mine.x() - theirs.x(), mine.z() - theirs.z()) >= 0.12 - 1e-9, + "The two heaps are drawn apart"); + assertTrue(FeltClearance.clear(mine.x(), mine.z(), List.of(footprint(table)), GAP)); + assertFalse(mine.tokens().isEmpty()); + } + + @Test void aRefundFliesFromWhereTheHeapIsDrawn() { + Table table = place(false); + Location card = boardCard(table); + stakeAt(player, card); + PlayerMock other = opponent(); + stakeAt(other, card); + PotPile mine = pileOf(table, player.getUniqueId()); + PotPile theirs = pileOf(table, other.getUniqueId()); + assertTrue(Math.hypot(mine.x() - theirs.x(), mine.z() - theirs.z()) >= 0.12 - 1e-9); + double x = theirs.x(); + double z = theirs.z(); + + List flights = new ArrayList<>(); + assertEquals(1, WagerEngine.get().refund(table, other.getUniqueId(), other, 0, flights, "return bet").moved()); + assertEquals(1, flights.size()); + assertEquals(x, flights.getFirst().pile().x(), 1e-9); + assertEquals(z, flights.getFirst().pile().z(), 1e-9); + } + @Test void aZeroGapLetsCoinsSitOnCardsAsBefore() { Cache.wagerCardGap = 0; Table table = place(false); diff --git a/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java b/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java index 5d16efd..deabbc0 100644 --- a/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java +++ b/src/test/java/net/tfminecraft/games/wager/MoneyTxTest.java @@ -225,7 +225,7 @@ void animationUsesSourceAnchorDestinationAndTrayFlag() { when(destination.isTray()).thenReturn(true); when(host.anchorFor(table, owner)).thenReturn(anchor); PayoutFlight flight = mock(PayoutFlight.class); - when(host.flights(table, expectedCoins.get(plan), anchor, target, true)).thenReturn(List.of(flight)); + when(host.flights(table, expectedCoins.get(plan), anchor, owner, target, true)).thenReturn(List.of(flight)); List flights = new ArrayList<>(); TxResult result = tx().move(source, destination, 5).animate(flights).commit(); assertSame(flights, result.flights()); @@ -239,7 +239,7 @@ void noSourceAnchorMeansNoAnimation() { List flights = new ArrayList<>(); assertEquals(5, tx().move(source, destination, 5).animate(flights).commit().moved()); assertTrue(flights.isEmpty()); - verify(host, never()).flights(any(), any(), any(), any(), anyBoolean()); + verify(host, never()).flights(any(), any(), any(), any(), any(), anyBoolean()); } @Test From 05e8c027de084e357d72dffa2ca3827b8367635e Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:30:04 +0200 Subject: [PATCH 4/5] Match payout flights to their heap by street and coin A flight now only takes off from a heap of the same street and coin, so refunding one street no longer flies from another street's heap at the same spot. A stake nobody placed is laid out with its whole bucket, which a partial withdrawal does not repeat, so its flights take any heap of that kind instead of guessing a slot. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/games/table/TableManager.java | 26 ++++++++--- .../games/table/TableManagerCardGapTest.java | 45 +++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index 1bdc551..2c3000c 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -1742,20 +1742,33 @@ private static double[] clearSpot(Table table, PotPile pile, List used) { if (sourceOwner == null) { return null; } + PotPile loose = null; for (PotPile pile : table.getPiles()) { - if (sourceOwner.equals(pile.ownerId()) && !used.contains(pile) - && Math.abs(pile.homeX() - flight.homeX()) < 1e-9 && Math.abs(pile.homeZ() - flight.homeZ()) < 1e-9 - && pile.item().isSimilar(flight.item())) { + if (!sourceOwner.equals(pile.ownerId()) || used.contains(pile) + || pile.streetId() != flight.streetId() || !pile.item().isSimilar(flight.item())) { + continue; + } + if (Math.abs(pile.homeX() - flight.homeX()) < 1e-9 && Math.abs(pile.homeZ() - flight.homeZ()) < 1e-9) { used.add(pile); return pile; } + if (loose == null && flight.stakeX() == null && pile.stakeX() == null) { + loose = pile; + } } - return null; + if (loose != null) { + used.add(loose); + } + return loose; } /** How far apart neighbouring heaps are laid out, the same step a spilling stake uses. */ @@ -2580,6 +2593,9 @@ private List makeFlights(Table table, List stakes, Location at.getX(), at.getZ()); pile.setPieces(add * per); pile.setStreetId(stake.streetId()); + if (spot != null) { + pile.setStakeSpot(spot.getX(), spot.getZ()); + } // Chips fly off from where the heap paying them is drawn, which may be beside a card. PotPile source = drawnSource(table, sourceOwner, pile, used); if (source != null) { diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java index 789d95b..fa6f447 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java @@ -286,6 +286,51 @@ private PotPile pileOf(Table table, UUID owner) { assertEquals(z, flights.getFirst().pile().z(), 1e-9); } + @Test void aStreetRefundFliesFromThatStreetsHeap() { + Table table = place(false); + Location card = boardCard(table); + stakeAt(player, card); + table.setStreet(2); + stakeAt(player, card); + List heaps = table.getPiles().stream().filter(p -> player.getUniqueId().equals(p.ownerId())).toList(); + assertEquals(2, heaps.size()); + PotPile second = heaps.stream().filter(p -> p.streetId() == 2).findFirst().orElseThrow(); + double x = second.x(); + double z = second.z(); + + List flights = new ArrayList<>(); + WagerEngine.get().refundStreet(table, player.getUniqueId(), 2, flights, "street refund"); + assertEquals(1, flights.size()); + assertEquals(x, flights.getFirst().pile().x(), 1e-9); + assertEquals(z, flights.getFirst().pile().z(), 1e-9); + } + + @Test void flightsFromAnUnplacedBucketLeaveFromItsHeapsOfTheirKind() { + Cache.wagerSilver = new WagerItemOverride("IRON_NUGGET", 5, null, null, null, null, + null, null, false, null); + Table table = place(false); + Location card = boardCard(table); + // Anchored on the card but never placed: the bucket lays both kinds out around the anchor. + WagerEngine.get().restore(table, player.getUniqueId(), new ItemStack(Material.GOLD_NUGGET), "gold", 1, + 1, table.street(), card.getX(), card.getZ()); + WagerEngine.get().restore(table, player.getUniqueId(), new ItemStack(Material.IRON_NUGGET), "silver", 5, + 1, table.street(), card.getX(), card.getZ()); + manager.syncAllChips(table); + Map> drawn = new HashMap<>(); + for (PotPile pile : table.getPiles()) drawn.put(pile.item().getType(), List.of(pile.x(), pile.z())); + assertEquals(2, drawn.size()); + + // Restored money is bound to the character it was staked by, as a load would. + table.wagerCharacters().put(player.getUniqueId(), net.tfminecraft.games.support.TestCharacters.ref(player)); + List flights = new ArrayList<>(); + // Only the second kind leaves, so its flight is laid out in a different slot than its heap. + assertEquals(5, WagerEngine.get().refund(table, player.getUniqueId(), player, 5, flights, "return bet").moved()); + assertEquals(1, flights.size()); + PotPile flight = flights.getFirst().pile(); + assertEquals(Material.IRON_NUGGET, flight.item().getType()); + assertEquals(drawn.get(Material.IRON_NUGGET), List.of(flight.x(), flight.z())); + } + @Test void aZeroGapLetsCoinsSitOnCardsAsBefore() { Cache.wagerCardGap = 0; Table table = place(false); From e138987555fbca9c9eea6b9a73834c98c663e2ec Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:42:04 +0200 Subject: [PATCH 5/5] Widen the clear-spot search with the gap and join unplaced stakes on click The search for room beside the cards now reaches a block past the configured card-gap, so a wide gap still finds a spot instead of leaving the pile on the card. Clicking a heap drawn beside a card whose stake nobody placed is now an unspotted deposit, so the coins join that stake instead of starting a placed heap at the layout spot. Co-Authored-By: Claude Opus 5.5 --- .../games/layout/FeltClearance.java | 8 ++++---- .../tfminecraft/games/table/TableManager.java | 13 +++++++----- .../games/layout/FeltClearanceTest.java | 7 +++++++ .../games/table/TableManagerCardGapTest.java | 20 +++++++++++++++++++ 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/main/java/net/tfminecraft/games/layout/FeltClearance.java b/src/main/java/net/tfminecraft/games/layout/FeltClearance.java index 2c4a630..0b944f4 100644 --- a/src/main/java/net/tfminecraft/games/layout/FeltClearance.java +++ b/src/main/java/net/tfminecraft/games/layout/FeltClearance.java @@ -15,7 +15,7 @@ public final class FeltClearance { /** The face in a card texture: 10 of 16 pixels wide and 14 of 16 tall, centred. */ static final float FACE_HALF_WIDTH = 5f / 16f; static final float FACE_HALF_HEIGHT = 7f / 16f; - /** How finely and how far a pile looks for room beside the cards. */ + /** How finely, and how far past the gap, a pile looks for room beside the cards. */ static final double STEP = 0.02; static final double REACH = 1.0; static final int DIRECTIONS = 32; @@ -95,14 +95,14 @@ public static boolean clear(double x, double z, List cards, double ga /** * The closest spot to {@code x}/{@code z} that is clear of the cards and that {@code filter} - * allows, searched out to {@link #REACH}. The spot itself when it is already clear, and also - * when nothing in reach is, so a pile never jumps somewhere arbitrary. + * allows, searched out to {@link #REACH} past the gap. The spot itself when it is already + * clear, and also when nothing in reach is, so a pile never jumps somewhere arbitrary. */ public static double[] nearestClear(double x, double z, List cards, double gap, SpotFilter filter) { if (cards.isEmpty() || gap <= 0 || clear(x, z, cards, gap)) { return new double[] {x, z}; } - int rings = (int) Math.round(REACH / STEP); + int rings = (int) Math.round((REACH + gap) / STEP); for (int ring = 1; ring <= rings; ring++) { double radius = ring * STEP; for (int i = 0; i < DIRECTIONS; i++) { diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index 2c3000c..c05c372 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -1805,9 +1805,9 @@ private void stepPilesOffCards(Table table) { } /** - * A click on a pile drawn beside a card adds to that pile, so the spot is where its stake - * lives (the pile's home when the layout placed that stake). Anywhere else the click is the - * spot, as before. + * A click on a pile drawn beside a card adds to that pile: the spot is where its stake was + * put, or null (no spot, so the coins join that stake) when the layout placed it. Anywhere + * else the click is the spot, as before. */ private static Location spotFor(Table table, UUID bucket, ItemStack one, Location hit) { double range = Cache.wagerMergeRange; @@ -1817,9 +1817,12 @@ private static Location spotFor(Table table, UUID bucket, ItemStack one, Locatio continue; } if (Math.hypot(pile.x() - hit.getX(), pile.z() - hit.getZ()) <= range) { + if (pile.stakeX() == null || pile.stakeZ() == null) { + return null; + } Location home = hit.clone(); - home.setX(pile.stakeX() != null ? pile.stakeX() : pile.homeX()); - home.setZ(pile.stakeZ() != null ? pile.stakeZ() : pile.homeZ()); + home.setX(pile.stakeX()); + home.setZ(pile.stakeZ()); return home; } } diff --git a/src/test/java/net/tfminecraft/games/layout/FeltClearanceTest.java b/src/test/java/net/tfminecraft/games/layout/FeltClearanceTest.java index 45b644e..e7b8ce0 100644 --- a/src/test/java/net/tfminecraft/games/layout/FeltClearanceTest.java +++ b/src/test/java/net/tfminecraft/games/layout/FeltClearanceTest.java @@ -94,6 +94,13 @@ void theFilterDecidesWhichSideThePileStepsTo() { assertTrue(FeltClearance.clear(spot[0], spot[1], cards, 0.1)); } + @Test + void aWideGapStillFindsRoom() { + List cards = List.of(card(0, 0, 0)); + double[] spot = FeltClearance.nearestClear(0, 0, cards, 0.9, (x, z) -> true); + assertTrue(FeltClearance.clear(spot[0], spot[1], cards, 0.9), Arrays.toString(spot)); + } + @Test void aPileStaysWhereItIsWhenItIsClearOrHasNowhereToGo() { List cards = List.of(card(0, 0, 0)); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java index fa6f447..4e95769 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java @@ -331,6 +331,26 @@ private PotPile pileOf(Table table, UUID owner) { assertEquals(drawn.get(Material.IRON_NUGGET), List.of(flight.x(), flight.z())); } + @Test void clickingTheMovedHeapOfAStakeNobodyPlacedAddsToThatStake() { + Table table = place(false); + Location card = boardCard(table); + WagerEngine.get().restore(table, player.getUniqueId(), new ItemStack(Material.GOLD_NUGGET), "gold", 1, + 3, table.street(), card.getX(), card.getZ()); + manager.syncAllChips(table); + PotPile heap = pileOf(table, player.getUniqueId()); + assertNull(heap.stakeX()); + assertNotEquals(heap.homeX(), heap.x(), 1e-9); + Location visible = card.clone(); + visible.setX(heap.x()); + visible.setZ(heap.z()); + + stakeAt(player, visible); + List stakes = table.ledger().stakes(player.getUniqueId()); + assertEquals(1, stakes.size()); + assertEquals(4, stakes.getFirst().count()); + assertFalse(stakes.getFirst().placed()); + } + @Test void aZeroGapLetsCoinsSitOnCardsAsBefore() { Cache.wagerCardGap = 0; Table table = place(false);