Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
<!-- Do not change unless you want different name for local builds. -->
<build.number>-LOCAL</build.number>
<!-- This allows to change between versions. -->
<build.version>0.1.1</build.version>
<build.version>0.2.0</build.version>
<!-- SonarCloud analysis (bentobox-world org). Requires the project to be
registered on SonarCloud as BentoBoxWorld_TradeWinds; the CI Sonar
step is non-fatal so an unregistered project never fails the build. -->
Expand Down
25 changes: 24 additions & 1 deletion src/main/java/world/bentobox/tradewinds/Settings.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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.")
Expand Down Expand Up @@ -558,7 +566,7 @@ private static Map<String, Integer> defaultEncounterAbandonShip() {
private static Map<String, Double> defaultFuelValues() {
Map<String, Double> 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);
Expand Down Expand Up @@ -871,6 +879,19 @@ private static Map<String, Double> 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);
Expand Down Expand Up @@ -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<String> getInnIslandTypes() { return innIslandTypes; }
public void setInnIslandTypes(List<String> innIslandTypes) { this.innIslandTypes = innIslandTypes; }
public double getCoastRoughness() { return coastRoughness; }
public void setCoastRoughness(double coastRoughness) { this.coastRoughness = coastRoughness; }
public double getIslandHilliness() { return islandHilliness; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ public boolean execute(User user, String label, List<String> 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);
}
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/world/bentobox/tradewinds/economy/MarketService.java
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,21 @@ public java.util.List<Material> 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.
Expand Down
33 changes: 32 additions & 1 deletion src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@ public final class TypeEconomy {
private static final Map<IslandType, List<Material>> 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
Expand All @@ -85,6 +91,31 @@ public static List<Material> 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<Material> 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<TradeCategory> produces(IslandType type) {
return PRODUCES.get(type);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
41 changes: 39 additions & 2 deletions src/main/java/world/bentobox/tradewinds/travel/BoatListener.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<BoatHold> 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
Expand All @@ -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);
Expand All @@ -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
Expand Down
Loading
Loading