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..0b944f4 --- /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 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; + + 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} 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 + gap) / 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..c05c372 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; @@ -176,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() { @@ -1048,6 +1049,7 @@ private void clearDealer(Player player) { void rebuildCardStacks(Table table) { rebuildShoeStack(table); rebuildDiscardStack(table); + stepPilesOffCards(table); } private void rebuildShoeStack(Table table) { @@ -1615,14 +1617,17 @@ public void syncBucketChips(Table table, UUID owner) { at.getX(), at.getZ()); pile.setPieces(add * per); pile.setStreetId(stake.streetId()); - 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); left -= add; } } + // 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. */ @@ -1645,6 +1650,185 @@ 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)); + } + } + + /** + * 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 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, 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)) { + return false; + } + boolean inTray = inTrayZone(table, probe); + if (tray || inTray) { + return tray && inTray; + } + return !felt || onFelt(table, probe); + }); + } + + /** + * The source bucket's drawn heap this flight leaves from, not yet claimed by another flight: + * the same coin and street, laid out on the same spot. A stake nobody placed is laid out by + * its whole bucket, which the withdrawn part does not repeat, so any heap of that kind will do. + */ + private static PotPile drawnSource(Table table, UUID sourceOwner, PotPile flight, Set used) { + if (sourceOwner == null) { + return null; + } + PotPile loose = null; + for (PotPile pile : table.getPiles()) { + 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; + } + } + if (loose != null) { + used.add(loose); + } + return loose; + } + + /** 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); + } + + /** + * 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; + } + 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 (!pile.tokens().isEmpty() + && Math.abs(old[0] - pile.x()) < 1e-6 && Math.abs(old[1] - pile.z()) < 1e-6) { + continue; + } + despawnPile(pile); + if (!rebuildPile(table, pile)) { + despawnPile(pile); + table.getPiles().remove(pile); + } + } + } + + /** + * 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; + 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) { + if (pile.stakeX() == null || pile.stakeZ() == null) { + return null; + } + Location home = hit.clone(); + home.setX(pile.stakeX()); + home.setZ(pile.stakeZ()); + 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. @@ -1664,7 +1848,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; @@ -1711,7 +1895,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); } @@ -1723,8 +1907,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 +2360,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); @@ -2380,12 +2566,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(); @@ -2406,14 +2596,30 @@ private List makeFlights(Table table, List stakes, Location at.getX(), at.getZ()); pile.setPieces(add * per); pile.setStreetId(stake.streetId()); - if (!rebuildPile(table, pile)) { - despawnPile(pile); - return flights; + if (spot != null) { + pile.setStakeSpot(spot.getX(), spot.getZ()); } - flights.add(toTray ? PayoutFlight.toTray(pile) : new PayoutFlight(pile, destId)); + // 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; } } + placeOffCards(table, fresh, matched, feltCards(table)); + for (PotPile pile : flying) { + if (!rebuildPile(table, pile)) { + despawnPile(pile); + return flights; + } + flights.add(toTray ? PayoutFlight.toTray(pile) : new PayoutFlight(pile, destId)); + } return flights; } @@ -2778,7 +2984,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 +3125,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 +3388,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/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/PotPile.java b/src/main/java/net/tfminecraft/games/wager/PotPile.java index 5766e38..3b42dd7 100644 --- a/src/main/java/net/tfminecraft/games/wager/PotPile.java +++ b/src/main/java/net/tfminecraft/games/wager/PotPile.java @@ -20,6 +20,10 @@ public final class PotPile { private int streetId; private double x; 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<>(); @@ -30,6 +34,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 +95,29 @@ 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; + } + + /** 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/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/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..e7b8ce0 --- /dev/null +++ b/src/test/java/net/tfminecraft/games/layout/FeltClearanceTest.java @@ -0,0 +1,112 @@ +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 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)); + 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..4e95769 --- /dev/null +++ b/src/test/java/net/tfminecraft/games/table/TableManagerCardGapTest.java @@ -0,0 +1,362 @@ +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)); + } + } + + /** 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()); + } + + 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 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 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); + Location card = boardCard(table); + stakeAt(player, card); + PotPile pile = onlyPile(table); + assertAt(card, pile.x(), pile.z()); + } +} 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