From b45d0a13c9249d46529d5b368cd2ccf64ba117ef Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 18:12:27 -0700 Subject: [PATCH] A boat is an identity, not a plank of wood - and ports have inns now Playtest, straight after the loaner fix: died, was lent the bamboo raft, walked over an oak hull at spawn and pocketed it with NO message of any kind; dropped the raft, picked it back up, and the oak boat vanished from the pack; /tw chart mentioned no OLD BOAT at all. Matching by MATERIAL instead of identity, twice. BoatCraftListener's refit broke up the first hull of the old boat's wood it found in the pack - with a stranger's oak hull aboard, that is somebody else's boat. StarterKit's spawn auto-launch had the same bug: it consumed whatever hull matched the type and launched OUR record out of it. Both now match the record id, fall back to an unstamped hull of that type only when nothing carries the id, and never touch a hull stamped with another record. Two oak boats are not the same boat; this rule has now cost four playtests. A hold changing hands in silence. Picking up a hull you own re-points your active boat and demotes whatever you were sailing - here, the loaner handed over a minute earlier - to an unowned OLD BOAT, and said nothing at all. boat.own-resumed / boat.own-aboard now say which boat is the hold and what became of the other, rate-limited through the existing prompt suppression so a full pack cannot spam it. A duplication route through the pickup listener: the "nothing to lose, claim it outright" branch removed the ground item and handed over a stamped copy but never cancelled the event, and vanilla adds the stack it captured regardless of the entity dying. Two items, one record - exactly what the rest of that class exists to prevent. Cancelled now. /tw chart could not answer "where is my old boat?". The no-argument chart raises holograms only, and worthMarking deliberately skips a boat that is carried or within 32 blocks, so at a quay it draws nothing and a sailor cannot tell "no OLD BOAT" from "the marker did not draw". It now sends the two text bearings as well. Economy, same session: pale oak was missing from every table - no base price and no fuel value - so the wood the top two hulls are built from was literally unsellable and unburnable. Mangrove had fuel but no price. Somewhere to sleep. "It's very difficult to sleep - there are no beds", and structurally so: this is an ocean, a bed needs a floor that is not moving, and until you claim an islet there is nowhere to put one. Ports whose type is in ocean.inn-island-types (LUXURY, AGRICULTURAL, FOREST) now carry an inn: a 5x5 room in the island's own planks, doorway to the plaza, lantern on the beam, and a made bed - both halves with agreeing Bed block data, because a half bed cannot be slept in. It stands across the plaza from the galley and never on the spawn pad. BED is not on the port deny list, so it is usable at visitor rank, and sleeping sets the respawn point the way any bed does. Farm ports sell white wool, cooked beef and mutton, plus ONE dyed colour of their own (TypeEconomy.localWool, seeded from the island cell - the same port always sells the same wool, so a sailor after a colour has somewhere to sail to). Outfitter stock, deliberately: trader-bought cargo may only leave the hold by sale or destruction, so wool bought as cargo could never become a bed. The 15 dyed wools needed explicit prices - most dyes come from flowers, which have no price, so recipe derivation returned zero for every colour and none of them were tradeable at all. Note that every map and list here had to change in config.yml too: BentoBox REPLACES them rather than merging, so a code default alone fixes nothing on a server that already has a config. Version 0.2.0. New tests: BoatIdentityTest (7) plus inn and flock-colour guards in IslandDecoratorTest. 610 tests green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- pom.xml | 2 +- .../world/bentobox/tradewinds/Settings.java | 25 ++- .../tradewinds/commands/TWChartCommand.java | 7 + .../tradewinds/economy/MarketService.java | 12 ++ .../tradewinds/economy/TypeEconomy.java | 33 ++- .../tradewinds/generator/IslandDecorator.java | 69 ++++++ .../tradewinds/travel/BoatCraftListener.java | 42 +++- .../tradewinds/travel/BoatListener.java | 41 +++- .../tradewinds/travel/StarterKit.java | 30 ++- src/main/resources/config.yml | 27 +++ src/main/resources/locales/en-US.yml | 2 + .../generator/IslandDecoratorTest.java | 37 ++++ .../tradewinds/travel/BoatIdentityTest.java | 198 ++++++++++++++++++ 13 files changed, 506 insertions(+), 19 deletions(-) create mode 100644 src/test/java/world/bentobox/tradewinds/travel/BoatIdentityTest.java 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); + } +}