diff --git a/pom.xml b/pom.xml index d7de67f..99f0609 100644 --- a/pom.xml +++ b/pom.xml @@ -56,7 +56,7 @@ -LOCAL - 0.1.1 + 0.2.0 diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 635e0e6..a45d675 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -157,6 +157,14 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "ocean.islet-structure-chance") private double isletStructureChance = 0.25; + @ConfigComment("Island types whose plaza carries an INN: a roofed room with a made bed you") + @ConfigComment("may sleep in. Sleeping is otherwise impossible at sea - there is nowhere to") + @ConfigComment("put a bed but an island of your own - and a bed you sleep in becomes your") + @ConfigComment("respawn point, vanilla-style. Empty disables inns everywhere. Only affects") + @ConfigComment("plazas not yet generated.") + @ConfigEntry(path = "ocean.inn-island-types") + private List innIslandTypes = new ArrayList<>(List.of("LUXURY", "AGRICULTURAL", "FOREST")); + @ConfigComment("How far a coastline wanders in and out from the island's nominal radius, as a") @ConfigComment("fraction of it: bays and headlands. 0 gives perfect circles - a radial mask") @ConfigComment("on its own draws a coin. Above about 0.3 coasts start breaking into fragments.") @@ -558,7 +566,7 @@ private static Map defaultEncounterAbandonShip() { private static Map defaultFuelValues() { Map map = new HashMap<>(); for (String log : List.of("OAK_LOG", "SPRUCE_LOG", "BIRCH_LOG", "JUNGLE_LOG", "ACACIA_LOG", "DARK_OAK_LOG", - "MANGROVE_LOG", "CHERRY_LOG")) { + "MANGROVE_LOG", "CHERRY_LOG", "PALE_OAK_LOG")) { map.put(log, 1.0); } map.put("COAL", 8.0); @@ -871,6 +879,19 @@ private static Map defaultBasePrices() { map.put("KELP", 3.0); map.put("OAK_LOG", 15.0); map.put("SPRUCE_LOG", 15.0); map.put("BIRCH_LOG", 15.0); map.put("DARK_OAK_LOG", 15.0); map.put("ACACIA_LOG", 15.0); map.put("JUNGLE_LOG", 15.0); map.put("CHERRY_LOG", 20.0); + // The rare woods gate the top of the boat ladder, so an islet stand of + // them is a find worth carrying home: priced above the common logs. + // Both were missing entirely until 2026-08-08, which made pale oak - + // the wood the best hulls are built from - literally unsellable. + map.put("MANGROVE_LOG", 18.0); map.put("PALE_OAK_LOG", 25.0); + // Dyed wool is the farm ports' speciality and the way a sailor makes a + // bed of their own. It cannot be left to the recipe engine: most dyes + // come from flowers, which have no price, so every colour derived to + // zero and was neither buyable nor sellable. + for (String colour : List.of("ORANGE", "MAGENTA", "LIGHT_BLUE", "YELLOW", "LIME", "PINK", "GRAY", + "LIGHT_GRAY", "CYAN", "PURPLE", "BLUE", "BROWN", "GREEN", "RED", "BLACK")) { + map.put(colour + "_WOOL", 24.0); + } map.put("STONE", 5.0); map.put("COBBLESTONE", 3.0); map.put("GRANITE", 4.0); map.put("DIORITE", 4.0); map.put("ANDESITE", 4.0); map.put("DEEPSLATE", 6.0); map.put("SAND", 3.0); map.put("GRAVEL", 3.0); map.put("COAL", 40.0); map.put("CHARCOAL", 30.0); map.put("RAW_IRON", 60.0); map.put("RAW_COPPER", 30.0); @@ -3218,6 +3239,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setMushroomIsletChance(double mushroomIsletChance) { this.mushroomIsletChance = mushroomIsletChance; } public double getIsletStructureChance() { return isletStructureChance; } public void setIsletStructureChance(double isletStructureChance) { this.isletStructureChance = isletStructureChance; } + public List getInnIslandTypes() { return innIslandTypes; } + public void setInnIslandTypes(List innIslandTypes) { this.innIslandTypes = innIslandTypes; } public double getCoastRoughness() { return coastRoughness; } public void setCoastRoughness(double coastRoughness) { this.coastRoughness = coastRoughness; } public double getIslandHilliness() { return islandHilliness; } diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java index 7f07fd1..d2c269d 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java @@ -48,6 +48,13 @@ public boolean execute(User user, String label, List args) { if (!wantList && getWorld().equals(user.getWorld())) { addon.getChartHolograms().show(user.getPlayer()); user.sendMessage("tradewinds.chart.holograms-shown"); + // ...and say where the boats are in words as well. The holograms + // skip a boat that is carried or within a few paces - sensible + // markers, useless answers: at a quay the compass drew nothing and + // a sailor could not tell "no OLD BOAT" from "not drawn" + // (playtest 2026-08-08). One line each, always. + reportBoat(addon, user, user.getLocation().getBlockX(), user.getLocation().getBlockZ(), false); + reportBoat(addon, user, user.getLocation().getBlockX(), user.getLocation().getBlockZ(), true); } else { chartList(addon, user); } diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index c028c76..9ccc979 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -89,9 +89,21 @@ public java.util.List outfitterCatalog(IslandSpec spec) { shelf.add(Material.CHARCOAL); } shelf.addAll(TypeEconomy.outfitterExtras(spec.type())); + // Every farm port's flock has its own colour, so bed-making is not a + // white-sheep monopoly and a sailor after a particular dye has a + // destination worth the trip + if (spec.type() == world.bentobox.tradewinds.ocean.IslandType.AGRICULTURAL) { + shelf.add(TypeEconomy.localWool(oceanSeed(), spec.cellX(), spec.cellZ())); + } return shelf; } + /** The ocean seed everything positional derives from. */ + private long oceanSeed() { + return addon.getOverWorld() == null ? 0L + : addon.getOceanEngine(addon.getOverWorld().getSeed()).getConfig().seed(); + } + /** * Buy stores delivered to the player's INVENTORY, not the hold: outfitter * supplies (food, gear, beds, rods). These are for using, not for resale. diff --git a/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java b/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java index 8a2a27c..e1001fd 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java @@ -61,7 +61,13 @@ public final class TypeEconomy { private static final Map> OUTFITTER_EXTRAS = Map.of( IslandType.INDUSTRIAL, List.of(Material.IRON_SWORD, Material.SHIELD, Material.IRON_HELMET, Material.IRON_CHESTPLATE, Material.IRON_LEGGINGS, Material.IRON_BOOTS), - IslandType.AGRICULTURAL, List.of(Material.WHITE_BED), + // The farms are where a sailor outfits a home: a bed to sleep in, + // white wool and meat off the flock. Wool has to be OUTFITTER + // stock, not trade cargo - trader-bought cargo may only leave the + // hold by sale or destruction, so wool in the hold could never + // become a bed (2026-08-08). + IslandType.AGRICULTURAL, + List.of(Material.WHITE_BED, Material.WHITE_WOOL, Material.COOKED_BEEF, Material.COOKED_MUTTON), // Rod and compass: the fisheries are the navigator's shop. A // compass bought by an island member leaves the counter bound to // their island (the ship's compass - see MarketService); for @@ -85,6 +91,31 @@ public static List outfitterExtras(IslandType type) { return OUTFITTER_EXTRAS.getOrDefault(type, List.of()); } + /** Salt for the flock's colour - one dyed wool per farm port. */ + private static final long SALT_WOOL = 0x5EEDF00DL; + + /** The dyed wools, white excepted: white is stocked at every farm. */ + private static final List DYED_WOOL = List.of(Material.ORANGE_WOOL, Material.MAGENTA_WOOL, + Material.LIGHT_BLUE_WOOL, Material.YELLOW_WOOL, Material.LIME_WOOL, Material.PINK_WOOL, + Material.GRAY_WOOL, Material.LIGHT_GRAY_WOOL, Material.CYAN_WOOL, Material.PURPLE_WOOL, + Material.BLUE_WOOL, Material.BROWN_WOOL, Material.GREEN_WOOL, Material.RED_WOOL, + Material.BLACK_WOOL); + + /** + * The colour this farm port's flock happens to be - seeded, so a given + * island always sells the same wool and a sailor after a particular colour + * has somewhere to sail TO. Pure arithmetic; no Bukkit state consulted. + * + * @param seed the ocean seed + * @param cellX island cell x + * @param cellZ island cell z + * @return the dyed wool this island stocks + */ + public static Material localWool(long seed, int cellX, int cellZ) { + long hash = world.bentobox.tradewinds.ocean.Hashing.cellHash(seed, cellX, cellZ, SALT_WOOL); + return DYED_WOOL.get((int) Math.floorMod(hash, DYED_WOOL.size())); + } + public static Set produces(IslandType type) { return PRODUCES.get(type); } diff --git a/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java b/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java index 64f698b..18188ed 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java +++ b/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java @@ -200,6 +200,22 @@ private void decoratePlaza(WorldInfo worldInfo, IslandSpec spec, DockPlan plan, int lz = plan.plazaZ() - (int) Math.round(Math.sin(plan.bearing()) * landmarkDist); buildLandmark(region, spec, lx, y, lz); + // The inn: the one place in the ocean a sailor can sleep. Everything + // else about this world is boats and water, and a bed needs a roof and + // a floor that is not moving (playtest 2026-08-08: "it's very difficult + // to sleep - there are no beds"). Ports that keep one are configurable; + // it stands across the plaza from the galley so the two do not fight + // over the same ground. + if (addon.getSettings().getInnIslandTypes().contains(spec.type().name())) { + double innAngle = plan.bearing() - 1.4; + int innDist = plan.plazaRadius() - 6; + int ix = plan.plazaX() + (int) Math.round(Math.cos(innAngle) * innDist); + int iz = plan.plazaZ() + (int) Math.round(Math.sin(innAngle) * innDist); + if (!onSpawnPad(plan, ix, iz)) { + buildInn(region, spec, ix, y, iz); + } + } + World world = Bukkit.getWorld(worldInfo.getUID()); spawnResidents(spec, plan, region, rand, world, y); } @@ -320,6 +336,59 @@ private long engineSeed(WorldInfo worldInfo) { return addon.getOceanEngine(worldInfo.getSeed()).getConfig().seed(); } + /** + * The inn: a 5x5 room in the island's own wood with a doorway facing the + * plaza, a lantern, and a made bed against the back wall. The bed is the + * whole point - it is the only one in the ocean, and sleeping in it sets + * the sleeper's respawn the way any bed does. + * + * @param region the populate region + * @param spec the island (its palette) + * @param x inn centre x + * @param y first air block above the plaza + * @param z inn centre z + */ + private void buildInn(LimitedRegion region, IslandSpec spec, int x, int y, int z) { + Material planks = IslandPalette.planks(spec.type()); + for (int dx = -2; dx <= 2; dx++) { + for (int dz = -2; dz <= 2; dz++) { + boolean wall = Math.abs(dx) == 2 || Math.abs(dz) == 2; + // Doorway: a two-block gap in the middle of the +x wall + boolean doorway = dx == 2 && dz == 0; + for (int dy = 0; dy <= 2; dy++) { + if (wall && !(doorway && dy < 2)) { + setIfPossible(region, x + dx, y + dy, z + dz, planks); + } + } + setIfPossible(region, x + dx, y + 3, z + dz, planks); // roof + } + } + setIfPossible(region, x, y + 2, z, Material.LANTERN); // hanging from the roof beam + placeBed(region, x - 1, y, z); + } + + /** + * Lay a bed with its two halves agreeing: a foot and a head block sharing a + * facing, or the bed is not a bed and cannot be slept in. + */ + private void placeBed(LimitedRegion region, int x, int y, int z) { + if (!region.isInRegion(x, y, z) || !region.isInRegion(x, y, z + 1)) { + return; + } + org.bukkit.block.data.BlockData footData = Bukkit.createBlockData(Material.WHITE_BED); + org.bukkit.block.data.BlockData headData = Bukkit.createBlockData(Material.WHITE_BED); + if (!(footData instanceof org.bukkit.block.data.type.Bed foot) + || !(headData instanceof org.bukkit.block.data.type.Bed head)) { + return; + } + foot.setPart(org.bukkit.block.data.type.Bed.Part.FOOT); + foot.setFacing(org.bukkit.block.BlockFace.SOUTH); + head.setPart(org.bukkit.block.data.type.Bed.Part.HEAD); + head.setFacing(org.bukkit.block.BlockFace.SOUTH); + region.setBlockData(x, y, z, foot); + region.setBlockData(x, y, z + 1, head); + } + private void buildStall(LimitedRegion region, IslandSpec spec, int sx, int y, int sz) { Material fence = IslandPalette.fence(spec.type()); Material canopy = IslandPalette.canopy(spec.type()); diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatCraftListener.java b/src/main/java/world/bentobox/tradewinds/travel/BoatCraftListener.java index 1858696..dc79da2 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/BoatCraftListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatCraftListener.java @@ -91,20 +91,46 @@ private void refitBoat(org.bukkit.entity.HumanEntity crafter, ItemStack result, org.bukkit.Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> stampCrafted(player, result.getType(), hold), 5L); } - removeOldBoat(player, old); + removeOldBoat(player, old, hold); User.getInstance(player).sendMessage("tradewinds.trade.boat-crafted", "[material]", world.bentobox.tradewinds.economy.PriceEngine.prettify(result.getType().name()), "[slots]", String.valueOf(newSlots)); } - private void removeOldBoat(Player player, Material old) { - if (old != null) { - for (ItemStack stack : player.getInventory().getContents()) { - if (stack != null && stack.getType() == old) { - stack.setAmount(stack.getAmount() - 1); - break; - } + /** + * Break up the hull the refit replaced - the one that IS this record, not + * merely one of the same wood. Matching by material ate any oak boat in + * the pack: a hull you were carrying for someone, an OLD BOAT you had just + * fished out of the sea, the lot (playtest 2026-08-08: "the oak boat + * disappeared"). A hull stamped with somebody else's record is never + * touched; an UNSTAMPED hull of the old type is taken only as a last + * resort, since that is what a legacy or hand-given boat looks like. + * + * @param player the crafter + * @param old the material of the hull being replaced + * @param hold their boat record - the identity that decides + */ + void removeOldBoat(Player player, Material old, + world.bentobox.tradewinds.dataobjects.BoatHold hold) { + if (old == null) { + return; + } + ItemStack unstamped = null; + for (ItemStack stack : player.getInventory().getContents()) { + if (stack == null || stack.getType() != old) { + continue; + } + String id = BoatService.boatId(stack); + if (hold.getUniqueId().equals(id)) { + stack.setAmount(stack.getAmount() - 1); + return; } + if (id == null && unstamped == null) { + unstamped = stack; + } + } + if (unstamped != null) { + unstamped.setAmount(unstamped.getAmount() - 1); } } diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java b/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java index a7bf9bf..6be1507 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java @@ -392,11 +392,23 @@ public void onPickup(EntityPickupItemEvent event) { return; } if (playerId.toString().equals(hold.getOwner())) { + // Their own boat, back in the pack - but SAY so. This pickup makes + // the hull their hold again, and if they were sailing something + // else (a respawn loaner, say) that boat is demoted to an OLD BOAT + // by the same stroke. It used to happen in total silence: the + // playtest picked an oak hull off the quay, heard nothing, and had + // no idea the raft it had just been lent was now flotsam + // (2026-08-08). + Optional demoted = addon.getHoldService().active(playerId) + .filter(other -> !other.getUniqueId().equals(hold.getUniqueId())); hold.setExpiresAt(0); addon.getHoldManager().save(hold); addon.getHoldManager().setActiveBoat(playerId, hold); addon.getBoatService().logbook("picked up by its owner", hold, event.getItem().getLocation()); - return; // their own boat, back in the pack + if (shouldPrompt(playerId, hold.getUniqueId())) { + announceOwnPickup(player, hold, demoted.orElse(null)); + } + return; } if (System.currentTimeMillis() < swapQuietUntil.getOrDefault(playerId, 0L)) { // Just swapped: leave the hull we shed alone for a moment rather @@ -405,7 +417,13 @@ public void onPickup(EntityPickupItemEvent event) { return; } if (addon.getHoldService().active(playerId).isEmpty()) { - // Nothing to lose: claim it outright, no dialog + // Nothing to lose: claim it outright, no dialog. The event MUST be + // cancelled even though the item entity is going: vanilla adds the + // stack it captured before the event regardless of the entity + // dying, so an uncancelled pickup landed the ground hull AND the + // stamped one giveBoatItem hands over - two items, one record, + // which is the duplication everything else here fights. + event.setCancelled(true); event.getItem().remove(); hold.setExpiresAt(0); addon.getBoatService().claim(player, hold); @@ -429,6 +447,25 @@ public void onPickup(EntityPickupItemEvent event) { }); } + /** + * Tell a sailor what pocketing their own hull just did: it is their hold + * again, and any other boat they had is now an unowned OLD BOAT lying + * where they left it. A hold changing hands is never a silent event. + * + * @param player the owner + * @param hold the hull they picked up + * @param demoted the boat this displaced, or null if it was already theirs + */ + private void announceOwnPickup(Player player, BoatHold hold, BoatHold demoted) { + String material = pretty(Material.matchMaterial(hold.getMaterial())); + if (demoted == null) { + User.getInstance(player).sendMessage("tradewinds.boat.own-aboard", MATERIAL_PLACEHOLDER, material); + return; + } + User.getInstance(player).sendMessage("tradewinds.boat.own-resumed", MATERIAL_PLACEHOLDER, material, + "[old]", pretty(Material.matchMaterial(demoted.getMaterial()))); + } + /** * Ignore boat items around this player for a moment: a swap or an * outright purchase has just shed a hull at their feet, and the pickup diff --git a/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java b/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java index 33e15ac..8dece4e 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java +++ b/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java @@ -85,21 +85,39 @@ public void give(Player player, Location spawn, boolean onWater) { } /** - * Remove one boat item the player OWNS from the inventory - the hold - * record's boat, so a stray vanilla boat is never mistaken for theirs. + * Remove the boat item that IS the player's record from the inventory, so + * launching it at spawn consumes their hull and nobody else's. Identity, + * not material: matching by type spent whichever oak boat came first in + * the pack, which could be a hull they had found or were ferrying (the + * same trap as the crafting refit, 2026-08-08). An unstamped hull of the + * right type is the fallback - a legacy or hand-given boat looks like + * that, and it is still the only candidate. * * @return the removed boat material, or null if none carried */ - private Material consumeBoatItem(Player player) { - Material owned = addon.getHoldService().boat(player); - if (owned == null) { + Material consumeBoatItem(Player player) { + var hold = addon.getHoldService().active(player.getUniqueId()).orElse(null); + if (hold == null) { return null; } + Material owned = Material.matchMaterial(hold.getMaterial()); + ItemStack unstamped = null; for (ItemStack stack : player.getInventory().getContents()) { - if (stack != null && stack.getType() == owned) { + if (stack == null) { + continue; + } + String id = BoatService.boatId(stack); + if (hold.getUniqueId().equals(id)) { stack.setAmount(stack.getAmount() - 1); return owned; } + if (id == null && owned != null && stack.getType() == owned && unstamped == null) { + unstamped = stack; + } + } + if (unstamped != null) { + unstamped.setAmount(unstamped.getAmount() - 1); + return owned; } return null; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index f685078..4c17a6a 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -63,6 +63,15 @@ ocean: # empty - there the biome itself is the find. 0 disables. Safe to change # mid-game: it only affects islets whose center chunk is not yet generated. islet-structure-chance: 0.25 + # Island types whose plaza carries an INN: a roofed room with a made bed you + # may sleep in. Sleeping is otherwise impossible at sea - there is nowhere to + # put a bed but an island of your own - and a bed you sleep in becomes your + # respawn point, vanilla-style. Empty disables inns everywhere. Only affects + # plazas not yet generated. + inn-island-types: + - LUXURY + - AGRICULTURAL + - FOREST # How far a coastline wanders in and out from the island's nominal radius, as a # fraction of it: bays and headlands. 0 gives perfect circles - a radial mask on # its own draws a coin. Above about 0.3 coasts start breaking into fragments. @@ -142,6 +151,7 @@ travel: DARK_OAK_LOG: 1.0 MANGROVE_LOG: 1.0 CHERRY_LOG: 1.0 + PALE_OAK_LOG: 1.0 DRIED_KELP_BLOCK: 4.0 COAL: 8.0 CHARCOAL: 8.0 @@ -415,6 +425,8 @@ economy: ACACIA_LOG: 15.0 JUNGLE_LOG: 15.0 CHERRY_LOG: 20.0 + MANGROVE_LOG: 18.0 + PALE_OAK_LOG: 25.0 STONE: 5.0 COBBLESTONE: 3.0 GRANITE: 4.0 @@ -442,6 +454,21 @@ economy: REDSTONE: 30.0 LEATHER: 40.0 WHITE_WOOL: 20.0 + ORANGE_WOOL: 24.0 + MAGENTA_WOOL: 24.0 + LIGHT_BLUE_WOOL: 24.0 + YELLOW_WOOL: 24.0 + LIME_WOOL: 24.0 + PINK_WOOL: 24.0 + GRAY_WOOL: 24.0 + LIGHT_GRAY_WOOL: 24.0 + CYAN_WOOL: 24.0 + PURPLE_WOOL: 24.0 + BLUE_WOOL: 24.0 + BROWN_WOOL: 24.0 + GREEN_WOOL: 24.0 + RED_WOOL: 24.0 + BLACK_WOOL: 24.0 STRING: 15.0 BEEF: 25.0 PORKCHOP: 25.0 diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index e805b43..afc5923 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -371,6 +371,8 @@ tradewinds: adrift-raft: "Adrift with no boat - the harbourmaster's [material] is in your pack." respawn-given: "The port lends you a [material] - she is yours, and your hold sails with her." respawn-given-replacing: "The port lends you a [material] - she is yours now. Your old boat lies adrift where you fell, cargo aboard; your chart still marks her." + own-aboard: "Your [material] is back in your pack - she is your hold again." + own-resumed: "Your [material] is your ship again - the [old] you were sailing is an OLD BOAT now, unowned where it lies. /tw chart list marks her." claimed-plain: "The [material] is yours now - your hold awaits its first cargo." upgraded-plain: "You take the [material] - [slots] cargo slots. Your old boat is broken up; your cargo is aboard." claimed: "You haul in the [material] - it is yours now, cargo and all." diff --git a/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java b/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java index 9964ad4..4978ff4 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java @@ -28,6 +28,8 @@ import org.mockito.stubbing.Answer; import world.bentobox.tradewinds.CommonTestSetup; +import world.bentobox.tradewinds.Settings; +import world.bentobox.tradewinds.economy.TypeEconomy; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.ocean.DockPlan; import world.bentobox.tradewinds.ocean.OceanConfig; @@ -61,6 +63,7 @@ class IslandDecoratorTest extends CommonTestSetup { public void setUp() throws Exception { super.setUp(); addon = mock(TradeWinds.class); + when(addon.getSettings()).thenReturn(new Settings()); engine = new OceanEngine(new OceanConfig(SEED, 2500, 160, 45, 1.0, 0, 5000, 70)); when(addon.getOceanEngine(anyLong())).thenReturn(engine); decorator = new IslandDecorator(addon); @@ -208,6 +211,40 @@ void testPierEndGetsBanner() { assertTrue(placed.contains(Material.LANTERN), "No lantern at pier end"); } + @Test + void testInnPortsGetABedYouCanSleepIn() { + // Sleeping is otherwise impossible in an ocean: no bed anywhere but an + // island of your own (playtest 2026-08-08). Ports on the inn list get + // a room with a made bed - both halves, or it is not a bed at all. + var innSpec = engine.islandsNear(0, 0, 40000).stream() + .filter(s -> new Settings().getInnIslandTypes() + .contains(s.type().name())) + .findFirst().orElseThrow(); + var innPlan = engine.dockPlan(innSpec); + org.bukkit.block.data.type.Bed bed = mock(org.bukkit.block.data.type.Bed.class); + mockedBukkit.when(() -> org.bukkit.Bukkit.createBlockData(Material.WHITE_BED)).thenReturn(bed); + LimitedRegion region = region(); + + decorator.populate(worldInfo(Environment.NORMAL), new Random(1), innPlan.plazaX() >> 4, + innPlan.plazaZ() >> 4, region); + + assertTrue(placed.contains(IslandPalette.planks(innSpec.type())), "The inn has no walls"); + verify(bed).setPart(org.bukkit.block.data.type.Bed.Part.FOOT); + verify(bed).setPart(org.bukkit.block.data.type.Bed.Part.HEAD); + verify(region, org.mockito.Mockito.times(2)).setBlockData(anyInt(), anyInt(), anyInt(), any()); + } + + @Test + void testFarmPortsKeepTheirOwnFlockColour() { + // Seeded, so a sailor after a particular colour has somewhere to sail + // TO - and the same port always sells the same wool + Material first = TypeEconomy.localWool(SEED, 3, 7); + assertEquals(first, TypeEconomy.localWool(SEED, 3, 7)); + assertTrue(java.util.stream.IntStream.range(0, 40) + .mapToObj(i -> TypeEconomy.localWool(SEED, i, i * 3)) + .distinct().count() > 1, "Every farm port sells the same colour - the roll is not rolling"); + } + @Test void testGolemCountsByBand() { assertEquals(3, IslandDecorator.golemCount(SecurityBand.SAFE)); diff --git a/src/test/java/world/bentobox/tradewinds/travel/BoatIdentityTest.java b/src/test/java/world/bentobox/tradewinds/travel/BoatIdentityTest.java new file mode 100644 index 0000000..1f25e5d --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/BoatIdentityTest.java @@ -0,0 +1,198 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +import org.bukkit.Material; +import org.bukkit.entity.Item; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import world.bentobox.tradewinds.CommonTestSetup; +import world.bentobox.tradewinds.Settings; +import world.bentobox.tradewinds.TestHolds; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.dataobjects.BoatHold; + +/** + * The 2026-08-08 playtest, as regression tests: a sailor died, was lent a + * bamboo raft, picked an oak hull off the quay in total silence, and then + * watched the oak boat vanish from the pack. Two of the three findings were + * the same old sin in new places - matching a boat by MATERIAL instead of + * identity - and the third was a hold changing hands with no word said. + * + * @author tastybento + */ +class BoatIdentityTest extends CommonTestSetup { + + private static final String WORLD_NAME = "tradewinds_world"; + + private TradeWinds addon; + private TestHolds holds; + private BoatService service; + private BoatListener listener; + private BoatCraftListener crafting; + private StarterKit kit; + private BoatHold mine; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + when(addon.getSettings()).thenReturn(new Settings()); + when(addon.getBoatRanks()).thenReturn(new BoatRanks(addon)); + holds = TestHolds.install(addon); + when(addon.getFuelService()).thenReturn(new FuelService(addon)); + service = new BoatService(addon); + when(addon.getBoatService()).thenReturn(service); + when(addon.getHoldService()).thenReturn(new HoldService(addon)); + when(addon.getOverWorld()).thenReturn(world); + when(world.getName()).thenReturn(WORLD_NAME); + when(inv.addItem(any())).thenReturn(new java.util.HashMap<>()); + listener = new BoatListener(addon); + crafting = new BoatCraftListener(addon); + kit = new StarterKit(addon); + mine = holds.giveBoat(uuid, Material.OAK_BOAT); + } + + /** A hull item carrying a given boat id (null for a plain vanilla hull). */ + private ItemStack hull(Material material, String boatId) { + ItemStack stack = mock(ItemStack.class); + ItemMeta meta = mock(ItemMeta.class); + PersistentDataContainer pdc = mock(PersistentDataContainer.class); + when(pdc.get(BoatService.BOAT_ID_KEY, PersistentDataType.STRING)).thenReturn(boatId); + when(meta.getPersistentDataContainer()).thenReturn(pdc); + when(stack.hasItemMeta()).thenReturn(true); + when(stack.getItemMeta()).thenReturn(meta); + when(stack.getType()).thenReturn(material); + when(stack.getAmount()).thenReturn(1); + return stack; + } + + // ------------------------------------------------------- crafting refit + + @Test + void testCraftRefitBreaksUpTheHullThatIsTheRecord() { + ItemStack ours = hull(Material.OAK_BOAT, mine.getUniqueId()); + when(inv.getContents()).thenReturn(new ItemStack[] { ours }); + + crafting.removeOldBoat(mockPlayer, Material.OAK_BOAT, mine); + + verify(ours).setAmount(0); + } + + @Test + void testCraftRefitSparesSomeoneElsesHullOfTheSameWood() { + // The playtest: an oak hull fished off the quay, sitting in the pack + // beside our own oak boat. Matching by material ate whichever came + // first - and the sailor's report was "the oak boat disappeared". + BoatHold theirs = holds.manager().create(Material.OAK_BOAT, null); + ItemStack stranger = hull(Material.OAK_BOAT, theirs.getUniqueId()); + ItemStack ours = hull(Material.OAK_BOAT, mine.getUniqueId()); + when(inv.getContents()).thenReturn(new ItemStack[] { stranger, ours }); + + crafting.removeOldBoat(mockPlayer, Material.OAK_BOAT, mine); + + verify(stranger, never()).setAmount(0); + verify(ours).setAmount(0); + } + + @Test + void testCraftRefitFallsBackToAnUnstampedHull() { + // A legacy or hand-given hull has no stamp; it is still the only + // candidate, so it may be broken up when nothing carries the id + ItemStack legacy = hull(Material.OAK_BOAT, null); + when(inv.getContents()).thenReturn(new ItemStack[] { legacy }); + + crafting.removeOldBoat(mockPlayer, Material.OAK_BOAT, mine); + + verify(legacy).setAmount(0); + } + + // ------------------------------------------------------ spawn auto-launch + + @Test + void testSpawnLaunchConsumesOurOwnHull() { + BoatHold theirs = holds.manager().create(Material.OAK_BOAT, null); + ItemStack stranger = hull(Material.OAK_BOAT, theirs.getUniqueId()); + ItemStack ours = hull(Material.OAK_BOAT, mine.getUniqueId()); + when(inv.getContents()).thenReturn(new ItemStack[] { stranger, ours }); + + kit.consumeBoatItem(mockPlayer); + + verify(stranger, never()).setAmount(0); + verify(ours).setAmount(0); + } + + @Test + void testSpawnLaunchTakesNothingWhenOnlyAStrangersHullIsCarried() { + BoatHold theirs = holds.manager().create(Material.OAK_BOAT, null); + ItemStack stranger = hull(Material.OAK_BOAT, theirs.getUniqueId()); + when(inv.getContents()).thenReturn(new ItemStack[] { stranger }); + + assertNull(kit.consumeBoatItem(mockPlayer), "A hull that is not ours is not ours to launch"); + verify(stranger, never()).setAmount(0); + } + + // ------------------------------------------------------------- pickups + + /** The pickup event for a hull lying on the ground. */ + private EntityPickupItemEvent pickup(ItemStack stack) { + Item ground = mock(Item.class); + when(ground.getItemStack()).thenReturn(stack); + when(ground.getLocation()).thenReturn(location); + EntityPickupItemEvent event = mock(EntityPickupItemEvent.class); + when(event.getEntity()).thenReturn(mockPlayer); + when(event.getItem()).thenReturn(ground); + return event; + } + + @Test + void testPickingUpYourOwnHullSaysWhatItDidToTheBoatYouWereSailing() { + // Died, was lent a raft, then walked over the old hull: the oak boat + // becomes the hold again and the loaner is flotsam. In silence, the + // sailor had no way to know either had happened. + BoatHold raft = holds.giveBoat(uuid, Material.BAMBOO_RAFT); + mine.setOwner(uuid.toString()); // still theirs, just not the active one + holds.manager().save(mine); + when(inv.getContents()).thenReturn(new ItemStack[0]); + + listener.onPickup(pickup(hull(Material.OAK_BOAT, mine.getUniqueId()))); + + org.mockito.ArgumentCaptor said = + org.mockito.ArgumentCaptor.forClass(net.kyori.adventure.text.Component.class); + verify(mockPlayer, org.mockito.Mockito.atLeastOnce()).sendMessage(said.capture()); + org.junit.jupiter.api.Assertions.assertTrue( + said.getAllValues().stream().map(Object::toString).anyMatch(s -> s.contains("own-resumed")), + "The sailor must be told their hold has moved to the hull they picked up"); + org.junit.jupiter.api.Assertions.assertEquals(mine.getUniqueId(), + holds.manager().activeBoat(uuid).map(BoatHold::getUniqueId).orElse(null)); + org.junit.jupiter.api.Assertions.assertTrue(raft.isUnowned(), "The loaner is an OLD BOAT now"); + } + + @Test + void testClaimingAnUnownedHullWithNoBoatCancelsTheVanillaPickup() { + // Vanilla adds the stack it captured whatever happens to the entity, + // so an uncancelled claim landed the ground hull AND the stamped copy + // giveBoatItem hands over: two items, one record. + holds.manager().setActiveBoat(uuid, null); + BoatHold flotsam = holds.manager().create(Material.SPRUCE_BOAT, null); + when(inv.getContents()).thenReturn(new ItemStack[0]); + EntityPickupItemEvent event = pickup(hull(Material.SPRUCE_BOAT, flotsam.getUniqueId())); + + listener.onPickup(event); + + verify(event).setCancelled(true); + } +}