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);
+ }
+}